@svelgo/core 0.1.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/index.html +19 -0
- package/package.json +28 -0
- package/src/component/Button.svelte +31 -0
- package/src/component/Label.svelte +16 -0
- package/src/runtime/builtins.ts +9 -0
- package/src/runtime/client.ts +68 -0
- package/src/runtime/proto.ts +44 -0
- package/src/runtime/registry.ts +15 -0
- package/src/runtime/state.ts +22 -0
- package/src/runtime/ui_descriptor.json +93 -0
- package/src/runtime/ws.ts +44 -0
- package/vite.config.ts +21 -0
package/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>SvelGo (Vite dev only)</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<!-- This file is only used by the Vite dev server for standalone testing.
|
|
10
|
+
In production, the Go server serves its own HTML shell. -->
|
|
11
|
+
<div id="svelgo-root"></div>
|
|
12
|
+
<script>
|
|
13
|
+
window.__SVELGO_PAGE_ID__ = "dev-page";
|
|
14
|
+
window.__SVELGO_STATE__ = "";
|
|
15
|
+
window.__SVELGO_MANIFEST__ = [];
|
|
16
|
+
</script>
|
|
17
|
+
<script type="module" src="/src/main.ts"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svelgo/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./runtime/client": "./src/runtime/client.ts",
|
|
7
|
+
"./runtime/ws": "./src/runtime/ws.ts",
|
|
8
|
+
"./runtime/state": "./src/runtime/state.ts",
|
|
9
|
+
"./runtime/proto": "./src/runtime/proto.ts",
|
|
10
|
+
"./runtime/registry": "./src/runtime/registry.ts",
|
|
11
|
+
"./ui_descriptor.json": "./src/runtime/ui_descriptor.json"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "vite",
|
|
15
|
+
"build": "vite build",
|
|
16
|
+
"preview": "vite preview"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
20
|
+
"protobufjs-cli": "^2.0.0",
|
|
21
|
+
"svelte": "^5.0.0",
|
|
22
|
+
"typescript": "^5.0.0",
|
|
23
|
+
"vite": "^6.3.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"protobufjs": "^7.4.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Writable } from 'svelte/store'
|
|
3
|
+
import { getComponentStore } from '../runtime/state'
|
|
4
|
+
import { sendEvent } from '../runtime/ws'
|
|
5
|
+
|
|
6
|
+
let { id }: { id: string } = $props()
|
|
7
|
+
|
|
8
|
+
let state: Record<string, unknown> = $state({})
|
|
9
|
+
$effect(() => {
|
|
10
|
+
const store = getComponentStore(id) as Writable<Record<string, unknown>>
|
|
11
|
+
return store.subscribe(s => { state = s })
|
|
12
|
+
})
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<button
|
|
16
|
+
onclick={() => sendEvent(id, 'click')}
|
|
17
|
+
disabled={!!state.disabled}
|
|
18
|
+
style="
|
|
19
|
+
background-color: {state.disabled ? '#9ca3af' : '#3b82f6'};
|
|
20
|
+
color: white;
|
|
21
|
+
padding: 0.75rem 1.5rem;
|
|
22
|
+
border: none;
|
|
23
|
+
border-radius: 0.5rem;
|
|
24
|
+
font-size: 1rem;
|
|
25
|
+
cursor: {state.disabled ? 'not-allowed' : 'pointer'};
|
|
26
|
+
opacity: {state.disabled ? '0.6' : '1'};
|
|
27
|
+
transition: background-color 0.15s ease;
|
|
28
|
+
"
|
|
29
|
+
>
|
|
30
|
+
{state.label ?? ''}
|
|
31
|
+
</button>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Writable } from 'svelte/store'
|
|
3
|
+
import { getComponentStore } from '../runtime/state'
|
|
4
|
+
|
|
5
|
+
let { id }: { id: string } = $props()
|
|
6
|
+
|
|
7
|
+
let state: Record<string, unknown> = $state({})
|
|
8
|
+
$effect(() => {
|
|
9
|
+
const store = getComponentStore(id) as Writable<Record<string, unknown>>
|
|
10
|
+
return store.subscribe(s => { state = s })
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<span style="font-size: 1rem; color: #1f2937;">
|
|
15
|
+
{state.text ?? ''}
|
|
16
|
+
</span>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { registerComponent } from './registry'
|
|
2
|
+
import { registerComponentDecoder, root } from './proto'
|
|
3
|
+
import Button from '../component/Button.svelte'
|
|
4
|
+
import Label from '../component/Label.svelte'
|
|
5
|
+
|
|
6
|
+
registerComponent('svelgo.Button', Button)
|
|
7
|
+
registerComponent('svelgo.Label', Label)
|
|
8
|
+
registerComponentDecoder('svelgo.Button', root.lookupType('ui.ButtonState'))
|
|
9
|
+
registerComponentDecoder('svelgo.Label', root.lookupType('ui.LabelState'))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mount } from 'svelte'
|
|
2
|
+
import { ComponentRegistry } from './registry'
|
|
3
|
+
import { initComponentState } from './state'
|
|
4
|
+
import { decodePageState, decodeComponentState } from './proto'
|
|
5
|
+
import { openWebSocket } from './ws'
|
|
6
|
+
import './builtins'
|
|
7
|
+
|
|
8
|
+
interface ManifestEntry {
|
|
9
|
+
id: string
|
|
10
|
+
type: string
|
|
11
|
+
slot: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Typed shape of the decoded PageState protobuf message.
|
|
15
|
+
interface DecodedPageState {
|
|
16
|
+
pageId: string
|
|
17
|
+
components: DecodedComponentState[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DecodedComponentState {
|
|
21
|
+
id: string
|
|
22
|
+
type: string
|
|
23
|
+
stateBytes: Uint8Array
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Typed shape of the globals injected by the Go HTML shell.
|
|
27
|
+
interface SvelGoWindow {
|
|
28
|
+
__SVELGO_PAGE_ID__: string
|
|
29
|
+
__SVELGO_STATE__: string
|
|
30
|
+
__SVELGO_MANIFEST__: ManifestEntry[]
|
|
31
|
+
__SVELGO_DEBUG__: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function bootstrap() {
|
|
35
|
+
const w = window as unknown as SvelGoWindow
|
|
36
|
+
const pageId = w.__SVELGO_PAGE_ID__
|
|
37
|
+
const stateBlob = w.__SVELGO_STATE__
|
|
38
|
+
const manifest = w.__SVELGO_MANIFEST__
|
|
39
|
+
|
|
40
|
+
// Decode the protobuf state blob and initialise per-component Svelte stores
|
|
41
|
+
if (stateBlob) {
|
|
42
|
+
const pageState = decodePageState(stateBlob) as unknown as DecodedPageState
|
|
43
|
+
if (w.__SVELGO_DEBUG__) {
|
|
44
|
+
console.debug('[svelgo init] page state', pageState)
|
|
45
|
+
}
|
|
46
|
+
for (const cs of (pageState.components ?? [])) {
|
|
47
|
+
const decoded = decodeComponentState(cs.type, cs.stateBytes)
|
|
48
|
+
initComponentState(cs.id, decoded)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Mount each Svelte component into the root div
|
|
53
|
+
const root = document.getElementById('svelgo-root')!
|
|
54
|
+
for (const entry of manifest) {
|
|
55
|
+
const Ctor = ComponentRegistry[entry.type]
|
|
56
|
+
if (!Ctor) {
|
|
57
|
+
console.warn(`SvelGo: unknown component type "${entry.type}"`)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
const target = document.createElement('div')
|
|
61
|
+
target.dataset.svelgoSlot = entry.id
|
|
62
|
+
root.appendChild(target)
|
|
63
|
+
mount(Ctor, { target, props: { id: entry.id } })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Open the WebSocket for live state updates
|
|
67
|
+
openWebSocket(pageId)
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import protobuf from 'protobufjs/light'
|
|
2
|
+
import descriptor from './ui_descriptor.json'
|
|
3
|
+
|
|
4
|
+
export const root = protobuf.Root.fromJSON(descriptor as protobuf.INamespace)
|
|
5
|
+
|
|
6
|
+
const PageStateMsg = root.lookupType('ui.PageState')
|
|
7
|
+
const ClientEventMsg = root.lookupType('ui.ClientEvent')
|
|
8
|
+
const StateUpdateMsg = root.lookupType('ui.StateUpdate')
|
|
9
|
+
|
|
10
|
+
// Map component type → protobuf message type for decoding state_bytes.
|
|
11
|
+
// Applications register their component decoders by calling registerComponentDecoder().
|
|
12
|
+
const componentTypes: Record<string, protobuf.Type> = {}
|
|
13
|
+
|
|
14
|
+
export function registerComponentDecoder(typeName: string, msgType: protobuf.Type) {
|
|
15
|
+
componentTypes[typeName] = msgType
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Decode the base64 protobuf blob injected into the HTML shell
|
|
19
|
+
export function decodePageState(base64blob: string): protobuf.Message {
|
|
20
|
+
const bytes = Uint8Array.from(atob(base64blob), c => c.charCodeAt(0))
|
|
21
|
+
return PageStateMsg.decode(bytes)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Decode a component's state_bytes using its declared type
|
|
25
|
+
export function decodeComponentState(type: string, stateBytes: Uint8Array): Record<string, unknown> {
|
|
26
|
+
const MsgType = componentTypes[type]
|
|
27
|
+
if (!MsgType) {
|
|
28
|
+
console.warn(`Unknown component type: ${type}`)
|
|
29
|
+
return {}
|
|
30
|
+
}
|
|
31
|
+
const msg = MsgType.decode(stateBytes)
|
|
32
|
+
return MsgType.toObject(msg, { defaults: true, longs: String, enums: String }) as Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Decode a binary WebSocket frame as a StateUpdate
|
|
36
|
+
export function decodeStateUpdate(buffer: ArrayBuffer): protobuf.Message {
|
|
37
|
+
return StateUpdateMsg.decode(new Uint8Array(buffer))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Encode a ClientEvent to send over the WebSocket
|
|
41
|
+
export function encodeClientEvent(payload: Record<string, unknown>): Uint8Array {
|
|
42
|
+
const msg = ClientEventMsg.create(payload)
|
|
43
|
+
return ClientEventMsg.encode(msg).finish()
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ComponentType } from 'svelte'
|
|
2
|
+
|
|
3
|
+
// Maps component type strings (from Go) to Svelte component constructors.
|
|
4
|
+
// Applications register their components by calling registerComponent().
|
|
5
|
+
export const ComponentRegistry: Record<string, ComponentType> = {}
|
|
6
|
+
|
|
7
|
+
export function registerComponent(typeName: string, ctor: ComponentType) {
|
|
8
|
+
if (ComponentRegistry[typeName] && ComponentRegistry[typeName] !== ctor) {
|
|
9
|
+
console.warn(
|
|
10
|
+
`SvelGo: registerComponent("${typeName}") is overwriting an existing registration. ` +
|
|
11
|
+
`This is likely a mistake — each type name should be registered exactly once.`
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
ComponentRegistry[typeName] = ctor
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { writable } from 'svelte/store'
|
|
2
|
+
|
|
3
|
+
type Store = ReturnType<typeof writable<Record<string, unknown>>>
|
|
4
|
+
|
|
5
|
+
const stores = new Map<string, Store>()
|
|
6
|
+
|
|
7
|
+
export function initComponentState(id: string, initialState: Record<string, unknown>) {
|
|
8
|
+
stores.set(id, writable(initialState))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getComponentStore(id: string): Store {
|
|
12
|
+
const store = stores.get(id)
|
|
13
|
+
if (!store) throw new Error(`No state store for component "${id}"`)
|
|
14
|
+
return store
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function updateComponentState(id: string, patch: Record<string, unknown>) {
|
|
18
|
+
const store = stores.get(id)
|
|
19
|
+
if (store) {
|
|
20
|
+
store.update(current => ({ ...current, ...patch }))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nested": {
|
|
3
|
+
"ui": {
|
|
4
|
+
"options": {
|
|
5
|
+
"go_package": "github.com/hawkhero/svelgo/gen/ui"
|
|
6
|
+
},
|
|
7
|
+
"nested": {
|
|
8
|
+
"PageState": {
|
|
9
|
+
"fields": {
|
|
10
|
+
"pageId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"id": 1
|
|
13
|
+
},
|
|
14
|
+
"components": {
|
|
15
|
+
"rule": "repeated",
|
|
16
|
+
"type": "ComponentState",
|
|
17
|
+
"id": 2
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"ComponentState": {
|
|
22
|
+
"fields": {
|
|
23
|
+
"id": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"id": 1
|
|
26
|
+
},
|
|
27
|
+
"type": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"id": 2
|
|
30
|
+
},
|
|
31
|
+
"stateBytes": {
|
|
32
|
+
"type": "bytes",
|
|
33
|
+
"id": 3
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"ClientEvent": {
|
|
38
|
+
"fields": {
|
|
39
|
+
"pageId": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"id": 1
|
|
42
|
+
},
|
|
43
|
+
"componentId": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"id": 2
|
|
46
|
+
},
|
|
47
|
+
"eventType": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"id": 3
|
|
50
|
+
},
|
|
51
|
+
"payload": {
|
|
52
|
+
"type": "bytes",
|
|
53
|
+
"id": 4
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"StateUpdate": {
|
|
58
|
+
"fields": {
|
|
59
|
+
"pageId": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"id": 1
|
|
62
|
+
},
|
|
63
|
+
"updatedComponents": {
|
|
64
|
+
"rule": "repeated",
|
|
65
|
+
"type": "ComponentState",
|
|
66
|
+
"id": 2
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"ButtonState": {
|
|
71
|
+
"fields": {
|
|
72
|
+
"label": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"id": 1
|
|
75
|
+
},
|
|
76
|
+
"disabled": {
|
|
77
|
+
"type": "bool",
|
|
78
|
+
"id": 2
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"LabelState": {
|
|
83
|
+
"fields": {
|
|
84
|
+
"text": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"id": 1
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { decodeStateUpdate, decodeComponentState, encodeClientEvent } from './proto'
|
|
2
|
+
import { updateComponentState } from './state'
|
|
3
|
+
|
|
4
|
+
// Typed shape of the decoded StateUpdate protobuf message.
|
|
5
|
+
interface DecodedStateUpdate {
|
|
6
|
+
pageId: string
|
|
7
|
+
updatedComponents: DecodedComponentState[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DecodedComponentState {
|
|
11
|
+
id: string
|
|
12
|
+
type: string
|
|
13
|
+
stateBytes: Uint8Array
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const debug = () => (window as unknown as { __SVELGO_DEBUG__: boolean }).__SVELGO_DEBUG__ === true
|
|
17
|
+
|
|
18
|
+
let socket: WebSocket
|
|
19
|
+
|
|
20
|
+
export function openWebSocket(pageId: string) {
|
|
21
|
+
const url = `ws://${location.host}/ws?page-id=${encodeURIComponent(pageId)}`
|
|
22
|
+
socket = new WebSocket(url)
|
|
23
|
+
socket.binaryType = 'arraybuffer'
|
|
24
|
+
|
|
25
|
+
socket.onmessage = (evt) => {
|
|
26
|
+
const update = decodeStateUpdate(evt.data) as unknown as DecodedStateUpdate
|
|
27
|
+
if (debug()) console.debug('[svelgo ws ←]', update)
|
|
28
|
+
for (const cs of (update.updatedComponents ?? [])) {
|
|
29
|
+
const decoded = decodeComponentState(cs.type, cs.stateBytes)
|
|
30
|
+
updateComponentState(cs.id, decoded)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
socket.onerror = (err) => console.error('SvelGo WebSocket error:', err)
|
|
35
|
+
socket.onclose = () => console.log('SvelGo WebSocket closed')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sendEvent(componentId: string, eventType: string, payload: Uint8Array = new Uint8Array()) {
|
|
39
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
|
40
|
+
const pageId = (window as unknown as { __SVELGO_PAGE_ID__: string }).__SVELGO_PAGE_ID__
|
|
41
|
+
const bytes = encodeClientEvent({ pageId, componentId, eventType, payload })
|
|
42
|
+
if (debug()) console.debug('[svelgo ws →]', { pageId, componentId, eventType })
|
|
43
|
+
socket.send(bytes)
|
|
44
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|
2
|
+
import { defineConfig } from 'vite'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [svelte()],
|
|
7
|
+
build: {
|
|
8
|
+
// Emit manifest.json so the Go asset resolver can find hashed filenames
|
|
9
|
+
manifest: true,
|
|
10
|
+
rollupOptions: {
|
|
11
|
+
input: resolve(__dirname, 'src/main.ts'),
|
|
12
|
+
},
|
|
13
|
+
// Output directly into the Go embed directory
|
|
14
|
+
outDir: resolve(__dirname, '../static'),
|
|
15
|
+
emptyOutDir: true,
|
|
16
|
+
},
|
|
17
|
+
server: {
|
|
18
|
+
// Allow the Go server to load scripts cross-origin in dev mode
|
|
19
|
+
cors: true,
|
|
20
|
+
},
|
|
21
|
+
})
|