@gratiaos/pad-core 1.0.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 +243 -0
- package/README.md +85 -0
- package/dist/catalog.d.ts +56 -0
- package/dist/catalog.js +128 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +96 -0
- package/dist/hooks/usePadMood.d.ts +15 -0
- package/dist/hooks/usePadMood.js +46 -0
- package/dist/id.d.ts +34 -0
- package/dist/id.js +76 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +23 -0
- package/dist/realtime/index.d.ts +24 -0
- package/dist/realtime/index.js +38 -0
- package/dist/realtime/port.d.ts +67 -0
- package/dist/realtime/port.js +64 -0
- package/dist/realtime/registry.d.ts +33 -0
- package/dist/realtime/registry.js +36 -0
- package/dist/realtime/sim-adapter.d.ts +16 -0
- package/dist/realtime/sim-adapter.js +79 -0
- package/dist/realtime/webrtc-adapter.d.ts +44 -0
- package/dist/realtime/webrtc-adapter.js +177 -0
- package/dist/registry.d.ts +48 -0
- package/dist/registry.js +41 -0
- package/dist/route.d.ts +39 -0
- package/dist/route.js +207 -0
- package/dist/scene-events.d.ts +48 -0
- package/dist/scene-events.js +113 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +17 -0
- package/package.json +36 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { decodeJson, envelope, ns } from './port';
|
|
2
|
+
/**
|
|
3
|
+
* SimAdapter — in-memory realtime port for local testing and playground use.
|
|
4
|
+
* --------------------------------------------------------------------------
|
|
5
|
+
* Behaves like a broadcast bus in a single browser tab. If multiple adapters
|
|
6
|
+
* exist in the same window, they share an EventTarget. This allows presence,
|
|
7
|
+
* scene, and pad messages to circulate during development without a network.
|
|
8
|
+
*/
|
|
9
|
+
const GLOBAL_BUS = globalThis.__GARDEN_SIM_BUS__ ?? new EventTarget();
|
|
10
|
+
globalThis.__GARDEN_SIM_BUS__ = GLOBAL_BUS;
|
|
11
|
+
let COUNTER = 0;
|
|
12
|
+
function newPeerId() {
|
|
13
|
+
COUNTER += 1;
|
|
14
|
+
return `peer:sim-${COUNTER.toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
15
|
+
}
|
|
16
|
+
export class SimAdapter {
|
|
17
|
+
constructor() {
|
|
18
|
+
this._circleId = null;
|
|
19
|
+
this._status = 'disconnected';
|
|
20
|
+
this._peerId = newPeerId();
|
|
21
|
+
}
|
|
22
|
+
status() {
|
|
23
|
+
return this._status;
|
|
24
|
+
}
|
|
25
|
+
myPeerId() {
|
|
26
|
+
return this._peerId;
|
|
27
|
+
}
|
|
28
|
+
async joinCircle(circleId) {
|
|
29
|
+
this._circleId = circleId;
|
|
30
|
+
this._status = 'connecting';
|
|
31
|
+
// Simulate async join delay
|
|
32
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
33
|
+
this._status = 'connected';
|
|
34
|
+
this._emitSystem(`joined circle ${circleId}`);
|
|
35
|
+
}
|
|
36
|
+
async leaveCircle() {
|
|
37
|
+
if (this._circleId) {
|
|
38
|
+
this._emitSystem(`left circle ${this._circleId}`);
|
|
39
|
+
}
|
|
40
|
+
this._circleId = null;
|
|
41
|
+
this._status = 'disconnected';
|
|
42
|
+
}
|
|
43
|
+
publish(topic, payload) {
|
|
44
|
+
if (!this._circleId)
|
|
45
|
+
return;
|
|
46
|
+
const body = payload instanceof Uint8Array ? decodeJson(payload) : payload;
|
|
47
|
+
const msg = envelope(this._circleId, this._peerId, topic, body);
|
|
48
|
+
const eventName = ns(this._circleId, topic);
|
|
49
|
+
const evt = new CustomEvent(eventName, { detail: msg });
|
|
50
|
+
GLOBAL_BUS.dispatchEvent(evt);
|
|
51
|
+
}
|
|
52
|
+
subscribe(topic, handler) {
|
|
53
|
+
const circleId = this._circleId;
|
|
54
|
+
if (!circleId)
|
|
55
|
+
return () => { };
|
|
56
|
+
const eventName = ns(circleId, topic);
|
|
57
|
+
const fn = (e) => {
|
|
58
|
+
const ce = e;
|
|
59
|
+
if (ce.detail.sender === this._peerId)
|
|
60
|
+
return; // don't echo self
|
|
61
|
+
const decoded = decodeJson(ce.detail.body);
|
|
62
|
+
handler(decoded, ce.detail);
|
|
63
|
+
};
|
|
64
|
+
GLOBAL_BUS.addEventListener(eventName, fn);
|
|
65
|
+
return () => GLOBAL_BUS.removeEventListener(eventName, fn);
|
|
66
|
+
}
|
|
67
|
+
_emitSystem(msg) {
|
|
68
|
+
if (!this._circleId)
|
|
69
|
+
return;
|
|
70
|
+
const evt = new CustomEvent(ns(this._circleId, 'presence'), {
|
|
71
|
+
detail: envelope(this._circleId, this._peerId, 'presence', { system: msg }),
|
|
72
|
+
});
|
|
73
|
+
GLOBAL_BUS.dispatchEvent(evt);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Factory to create a new SimAdapter easily */
|
|
77
|
+
export function createSimAdapter() {
|
|
78
|
+
return new SimAdapter();
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type CircleId, type PeerId, type Topic, type MessageEnvelope, type RealtimePort } from './port';
|
|
2
|
+
/**
|
|
3
|
+
* WebRtcAdapter — hybrid P2P realtime adapter (signaling + DataChannels)
|
|
4
|
+
* ---------------------------------------------------------------------
|
|
5
|
+
* This is a scaffold for the full peer-to-peer implementation.
|
|
6
|
+
* It connects peers via a lightweight signaling WebSocket and uses
|
|
7
|
+
* WebRTC DataChannels for encrypted, low-latency message exchange.
|
|
8
|
+
*
|
|
9
|
+
* For now, this adapter only logs connection flow and mirrors the API.
|
|
10
|
+
* You can expand it to full WebRTC mesh or hub-spoke topology later.
|
|
11
|
+
*/
|
|
12
|
+
export interface WebRtcConfig {
|
|
13
|
+
signalUrl: string;
|
|
14
|
+
circleId: CircleId;
|
|
15
|
+
peerId?: PeerId;
|
|
16
|
+
iceServers?: RTCIceServer[];
|
|
17
|
+
}
|
|
18
|
+
export declare class WebRtcAdapter implements RealtimePort {
|
|
19
|
+
private cfg;
|
|
20
|
+
private _peerId;
|
|
21
|
+
private _circleId;
|
|
22
|
+
private _status;
|
|
23
|
+
private _ws;
|
|
24
|
+
private _peers;
|
|
25
|
+
private _channels;
|
|
26
|
+
private _handlers;
|
|
27
|
+
constructor(cfg: WebRtcConfig);
|
|
28
|
+
status(): "disconnected" | "connecting" | "connected";
|
|
29
|
+
myPeerId(): string;
|
|
30
|
+
joinCircle(circleId: CircleId): Promise<void>;
|
|
31
|
+
leaveCircle(): Promise<void>;
|
|
32
|
+
publish<T = unknown>(topic: Topic, payload: T | Uint8Array): void;
|
|
33
|
+
subscribe<T = unknown>(topic: Topic, handler: (payload: T, envelope: MessageEnvelope<T>) => void): () => void;
|
|
34
|
+
private _connectSignal;
|
|
35
|
+
private _sendSignal;
|
|
36
|
+
private _handleSignal;
|
|
37
|
+
private _createConnection;
|
|
38
|
+
private _handleOffer;
|
|
39
|
+
private _handleAnswer;
|
|
40
|
+
private _handleIce;
|
|
41
|
+
private _attachChannel;
|
|
42
|
+
}
|
|
43
|
+
/** Factory helper */
|
|
44
|
+
export declare function createWebRtcAdapter(cfg: WebRtcConfig): RealtimePort;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { decodeJson, envelope } from './port';
|
|
2
|
+
export class WebRtcAdapter {
|
|
3
|
+
constructor(cfg) {
|
|
4
|
+
this.cfg = cfg;
|
|
5
|
+
this._circleId = null;
|
|
6
|
+
this._status = 'disconnected';
|
|
7
|
+
this._ws = null;
|
|
8
|
+
this._peers = new Map();
|
|
9
|
+
this._channels = new Map();
|
|
10
|
+
this._handlers = new Map();
|
|
11
|
+
this._peerId = cfg.peerId || `peer:rtc-${Math.random().toString(36).slice(2, 8)}`;
|
|
12
|
+
}
|
|
13
|
+
status() {
|
|
14
|
+
return this._status;
|
|
15
|
+
}
|
|
16
|
+
myPeerId() {
|
|
17
|
+
return this._peerId;
|
|
18
|
+
}
|
|
19
|
+
async joinCircle(circleId) {
|
|
20
|
+
this._circleId = circleId;
|
|
21
|
+
this._status = 'connecting';
|
|
22
|
+
try {
|
|
23
|
+
await this._connectSignal();
|
|
24
|
+
this._status = 'connected';
|
|
25
|
+
console.info('[webrtc] connected to signaling hub');
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
console.error('[webrtc] signaling failed', err);
|
|
29
|
+
this._status = 'disconnected';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async leaveCircle() {
|
|
33
|
+
for (const ch of this._channels.values())
|
|
34
|
+
ch.close();
|
|
35
|
+
for (const pc of this._peers.values())
|
|
36
|
+
pc.close();
|
|
37
|
+
this._channels.clear();
|
|
38
|
+
this._peers.clear();
|
|
39
|
+
if (this._ws) {
|
|
40
|
+
this._ws.close();
|
|
41
|
+
this._ws = null;
|
|
42
|
+
}
|
|
43
|
+
this._status = 'disconnected';
|
|
44
|
+
console.info('[webrtc] left circle');
|
|
45
|
+
}
|
|
46
|
+
publish(topic, payload) {
|
|
47
|
+
if (!this._circleId)
|
|
48
|
+
return;
|
|
49
|
+
const body = payload instanceof Uint8Array ? decodeJson(payload) : payload;
|
|
50
|
+
const msg = envelope(this._circleId, this._peerId, topic, body);
|
|
51
|
+
const json = JSON.stringify(msg);
|
|
52
|
+
// Broadcast to all open DataChannels
|
|
53
|
+
for (const [pid, ch] of this._channels.entries()) {
|
|
54
|
+
if (ch.readyState === 'open') {
|
|
55
|
+
ch.send(json);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
subscribe(topic, handler) {
|
|
60
|
+
const key = topic;
|
|
61
|
+
const set = this._handlers.get(key) || new Set();
|
|
62
|
+
set.add(handler);
|
|
63
|
+
this._handlers.set(key, set);
|
|
64
|
+
return () => set.delete(handler);
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Signaling logic (simplified placeholder)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
async _connectSignal() {
|
|
70
|
+
const { signalUrl } = this.cfg;
|
|
71
|
+
this._ws = new WebSocket(signalUrl);
|
|
72
|
+
this._ws.addEventListener('open', () => {
|
|
73
|
+
this._sendSignal({ type: 'join', circleId: this._circleId, peerId: this._peerId });
|
|
74
|
+
});
|
|
75
|
+
this._ws.addEventListener('message', (e) => this._handleSignal(JSON.parse(e.data)));
|
|
76
|
+
this._ws.addEventListener('close', () => {
|
|
77
|
+
this._status = 'disconnected';
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
_sendSignal(obj) {
|
|
81
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
82
|
+
this._ws.send(JSON.stringify(obj));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async _handleSignal(msg) {
|
|
86
|
+
const { type, from, data } = msg;
|
|
87
|
+
if (from === this._peerId)
|
|
88
|
+
return;
|
|
89
|
+
switch (type) {
|
|
90
|
+
case 'offer':
|
|
91
|
+
await this._handleOffer(from, data);
|
|
92
|
+
break;
|
|
93
|
+
case 'answer':
|
|
94
|
+
await this._handleAnswer(from, data);
|
|
95
|
+
break;
|
|
96
|
+
case 'ice':
|
|
97
|
+
await this._handleIce(from, data);
|
|
98
|
+
break;
|
|
99
|
+
case 'peers':
|
|
100
|
+
for (const p of data) {
|
|
101
|
+
if (p !== this._peerId)
|
|
102
|
+
this._createConnection(p, true);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
console.debug('[webrtc] unknown signal', msg);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async _createConnection(peerId, initiator) {
|
|
110
|
+
const pc = new RTCPeerConnection({ iceServers: this.cfg.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }] });
|
|
111
|
+
this._peers.set(peerId, pc);
|
|
112
|
+
const channel = initiator ? pc.createDataChannel('garden') : null;
|
|
113
|
+
if (channel)
|
|
114
|
+
this._attachChannel(peerId, channel);
|
|
115
|
+
pc.ondatachannel = (e) => {
|
|
116
|
+
this._attachChannel(peerId, e.channel);
|
|
117
|
+
};
|
|
118
|
+
pc.onicecandidate = (e) => {
|
|
119
|
+
if (e.candidate)
|
|
120
|
+
this._sendSignal({ type: 'ice', from: this._peerId, to: peerId, data: e.candidate });
|
|
121
|
+
};
|
|
122
|
+
if (initiator) {
|
|
123
|
+
const offer = await pc.createOffer();
|
|
124
|
+
await pc.setLocalDescription(offer);
|
|
125
|
+
this._sendSignal({ type: 'offer', from: this._peerId, to: peerId, data: offer });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async _handleOffer(from, offer) {
|
|
129
|
+
const pc = new RTCPeerConnection({ iceServers: this.cfg.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }] });
|
|
130
|
+
this._peers.set(from, pc);
|
|
131
|
+
pc.ondatachannel = (e) => this._attachChannel(from, e.channel);
|
|
132
|
+
pc.onicecandidate = (e) => {
|
|
133
|
+
if (e.candidate)
|
|
134
|
+
this._sendSignal({ type: 'ice', from: this._peerId, to: from, data: e.candidate });
|
|
135
|
+
};
|
|
136
|
+
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
137
|
+
const answer = await pc.createAnswer();
|
|
138
|
+
await pc.setLocalDescription(answer);
|
|
139
|
+
this._sendSignal({ type: 'answer', from: this._peerId, to: from, data: answer });
|
|
140
|
+
}
|
|
141
|
+
async _handleAnswer(from, answer) {
|
|
142
|
+
const pc = this._peers.get(from);
|
|
143
|
+
if (!pc)
|
|
144
|
+
return;
|
|
145
|
+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
146
|
+
}
|
|
147
|
+
async _handleIce(from, candidate) {
|
|
148
|
+
const pc = this._peers.get(from);
|
|
149
|
+
if (!pc)
|
|
150
|
+
return;
|
|
151
|
+
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
152
|
+
}
|
|
153
|
+
_attachChannel(peerId, ch) {
|
|
154
|
+
this._channels.set(peerId, ch);
|
|
155
|
+
ch.onopen = () => console.debug('[webrtc] channel open', peerId);
|
|
156
|
+
ch.onclose = () => console.debug('[webrtc] channel closed', peerId);
|
|
157
|
+
ch.onmessage = (e) => {
|
|
158
|
+
try {
|
|
159
|
+
const msg = JSON.parse(e.data);
|
|
160
|
+
const handlers = this._handlers.get(msg.type);
|
|
161
|
+
if (!handlers)
|
|
162
|
+
return;
|
|
163
|
+
for (const h of handlers) {
|
|
164
|
+
const decoded = decodeJson(msg.body);
|
|
165
|
+
h(decoded, msg);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.warn('[webrtc] bad message', err);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Factory helper */
|
|
175
|
+
export function createWebRtcAdapter(cfg) {
|
|
176
|
+
return new WebRtcAdapter(cfg);
|
|
177
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pad registry: a tiny in-memory catalog of pads.
|
|
3
|
+
*
|
|
4
|
+
* - Keeps a map of PadManifest by id.
|
|
5
|
+
* - Agnostic to React/runtime; safe for SSR and tests.
|
|
6
|
+
* - Does not enforce ordering; use `sortPads` for stable sort when rendering.
|
|
7
|
+
*
|
|
8
|
+
* Typical usage
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { createRegistry, sortPads, globalRegistry } from '@gratiaos/pad-core';
|
|
11
|
+
*
|
|
12
|
+
* // 1) Local registry
|
|
13
|
+
* const reg = createRegistry();
|
|
14
|
+
* reg.register({ id: 'town', title: 'Town', icon: '🌆', mount });
|
|
15
|
+
* const padsForUi = sortPads(reg.list());
|
|
16
|
+
*
|
|
17
|
+
* // 2) Or use the shared global registry (opt-in)
|
|
18
|
+
* globalRegistry.register({ id: 'value', title: 'Value Bridge', icon: '💱', mount });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { PadId, PadManifest } from './types.js';
|
|
22
|
+
export interface PadRegistry {
|
|
23
|
+
/** Insert or replace a pad manifest by id. */
|
|
24
|
+
register(manifest: PadManifest): void;
|
|
25
|
+
/** Fetch a manifest by id (undefined if absent). */
|
|
26
|
+
get(id: PadId): PadManifest | undefined;
|
|
27
|
+
/** Return all manifests (unsorted). */
|
|
28
|
+
list(): PadManifest[];
|
|
29
|
+
/** True if a manifest with this id exists. */
|
|
30
|
+
has(id: PadId): boolean;
|
|
31
|
+
/** Remove all manifests. */
|
|
32
|
+
clear(): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a fresh registry with optional initial manifests.
|
|
36
|
+
* This is a pure, in-memory structure – no I/O, no global state.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createRegistry(initial?: ReadonlyArray<PadManifest>): PadRegistry;
|
|
39
|
+
/** Convenience: stable sorting by title (case-insensitive), then id. */
|
|
40
|
+
export declare function sortPads(pads: PadManifest[]): PadManifest[];
|
|
41
|
+
/** Batch helper to register multiple manifests at once. */
|
|
42
|
+
export declare function registerAll(registry: PadRegistry, manifests: ReadonlyArray<PadManifest>): void;
|
|
43
|
+
/**
|
|
44
|
+
* Shared global registry (optional).
|
|
45
|
+
* Useful for apps that prefer a singleton catalog.
|
|
46
|
+
* Tests can import and call `globalRegistry.clear()` to isolate cases.
|
|
47
|
+
*/
|
|
48
|
+
export declare const globalRegistry: PadRegistry;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a fresh registry with optional initial manifests.
|
|
3
|
+
* This is a pure, in-memory structure – no I/O, no global state.
|
|
4
|
+
*/
|
|
5
|
+
export function createRegistry(initial) {
|
|
6
|
+
const map = new Map();
|
|
7
|
+
for (const m of initial ?? [])
|
|
8
|
+
map.set(m.id, m);
|
|
9
|
+
return {
|
|
10
|
+
register(m) {
|
|
11
|
+
map.set(m.id, m);
|
|
12
|
+
},
|
|
13
|
+
get(id) {
|
|
14
|
+
return map.get(id);
|
|
15
|
+
},
|
|
16
|
+
list() {
|
|
17
|
+
return Array.from(map.values());
|
|
18
|
+
},
|
|
19
|
+
has(id) {
|
|
20
|
+
return map.has(id);
|
|
21
|
+
},
|
|
22
|
+
clear() {
|
|
23
|
+
map.clear();
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Convenience: stable sorting by title (case-insensitive), then id. */
|
|
28
|
+
export function sortPads(pads) {
|
|
29
|
+
return [...pads].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' }) || a.id.localeCompare(b.id));
|
|
30
|
+
}
|
|
31
|
+
/** Batch helper to register multiple manifests at once. */
|
|
32
|
+
export function registerAll(registry, manifests) {
|
|
33
|
+
for (const m of manifests)
|
|
34
|
+
registry.register(m);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Shared global registry (optional).
|
|
38
|
+
* Useful for apps that prefer a singleton catalog.
|
|
39
|
+
* Tests can import and call `globalRegistry.clear()` to isolate cases.
|
|
40
|
+
*/
|
|
41
|
+
export const globalRegistry = createRegistry();
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PadId, PadManifest, PadRouteOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Garden Pads — routing helpers (hash | query | path)
|
|
4
|
+
* ---------------------------------------------------
|
|
5
|
+
* Framework-agnostic utilities for reading/writing the current pad id from
|
|
6
|
+
* the URL using one of three strategies:
|
|
7
|
+
* - hash : /page#pad=town
|
|
8
|
+
* - query : /page?pad=town
|
|
9
|
+
* - path : /pads/town (with a `pathPrefix`, e.g. "/pads")
|
|
10
|
+
*
|
|
11
|
+
* The default strategy is `"auto"` which *reads* from whichever location
|
|
12
|
+
* already contains a value (hash → query → path), and *writes* to `hash`
|
|
13
|
+
* unless a manifest/opts overrides it.
|
|
14
|
+
*
|
|
15
|
+
* All functions are SSR-safe: if `window` is absent they no-op or return
|
|
16
|
+
* best-effort strings.
|
|
17
|
+
*/
|
|
18
|
+
export declare const DEFAULT_HASH_KEY: "pad";
|
|
19
|
+
/** Read current pad id according to route options (defaults to auto). */
|
|
20
|
+
export declare function getActivePadId(manifest?: PadManifest, opts?: PadRouteOptions): PadId | null;
|
|
21
|
+
/** Set or clear current pad id (uses replaceState by default). */
|
|
22
|
+
export declare function setActivePadId(id: PadId | null, manifest?: PadManifest, opts?: PadRouteOptions & {
|
|
23
|
+
replace?: boolean;
|
|
24
|
+
}): void;
|
|
25
|
+
/** Remove any active pad indicator. */
|
|
26
|
+
export declare function clearActivePadId(manifest?: PadManifest, opts?: PadRouteOptions & {
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
}): void;
|
|
29
|
+
/** Build an href that would navigate to a given pad id. */
|
|
30
|
+
export declare function hrefForPad(manifest: PadManifest, opts?: PadRouteOptions & {
|
|
31
|
+
basePath?: string;
|
|
32
|
+
}): string;
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to route changes (hashchange/popstate) and receive the current pad id.
|
|
35
|
+
* Returns an unsubscribe function.
|
|
36
|
+
*/
|
|
37
|
+
export declare function onPadRouteChange(fn: (id: PadId | null) => void, manifest?: PadManifest, opts?: PadRouteOptions & {
|
|
38
|
+
fireNow?: boolean;
|
|
39
|
+
}): () => void;
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { DEFAULT_ROUTE_MODE, DEFAULT_QUERY_KEY } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Garden Pads — routing helpers (hash | query | path)
|
|
4
|
+
* ---------------------------------------------------
|
|
5
|
+
* Framework-agnostic utilities for reading/writing the current pad id from
|
|
6
|
+
* the URL using one of three strategies:
|
|
7
|
+
* - hash : /page#pad=town
|
|
8
|
+
* - query : /page?pad=town
|
|
9
|
+
* - path : /pads/town (with a `pathPrefix`, e.g. "/pads")
|
|
10
|
+
*
|
|
11
|
+
* The default strategy is `"auto"` which *reads* from whichever location
|
|
12
|
+
* already contains a value (hash → query → path), and *writes* to `hash`
|
|
13
|
+
* unless a manifest/opts overrides it.
|
|
14
|
+
*
|
|
15
|
+
* All functions are SSR-safe: if `window` is absent they no-op or return
|
|
16
|
+
* best-effort strings.
|
|
17
|
+
*/
|
|
18
|
+
// Defaults
|
|
19
|
+
export const DEFAULT_HASH_KEY = DEFAULT_QUERY_KEY; // default hash key (mirrors query key)
|
|
20
|
+
// ── internals ────────────────────────────────────────────────────────────────
|
|
21
|
+
function safeWindow() {
|
|
22
|
+
try {
|
|
23
|
+
return window;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function ensureLeadingSlash(s) {
|
|
30
|
+
if (!s)
|
|
31
|
+
return '/';
|
|
32
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
33
|
+
}
|
|
34
|
+
function readHashParams(w = safeWindow()) {
|
|
35
|
+
const hash = w?.location?.hash ?? '';
|
|
36
|
+
return new URLSearchParams(String(hash).replace(/^#/, ''));
|
|
37
|
+
}
|
|
38
|
+
function writeHash(params, basePath, replace, w = safeWindow()) {
|
|
39
|
+
if (!w)
|
|
40
|
+
return;
|
|
41
|
+
const next = params.toString();
|
|
42
|
+
const newHash = next ? `#${next}` : '';
|
|
43
|
+
const base = basePath ?? w.location.pathname;
|
|
44
|
+
const href = `${base}${newHash}`;
|
|
45
|
+
if (replace)
|
|
46
|
+
w.history.replaceState(null, '', href);
|
|
47
|
+
else
|
|
48
|
+
w.history.pushState(null, '', href);
|
|
49
|
+
}
|
|
50
|
+
function readQueryParams(w = safeWindow()) {
|
|
51
|
+
const search = w?.location?.search ?? '';
|
|
52
|
+
return new URLSearchParams(String(search).replace(/^\?/, ''));
|
|
53
|
+
}
|
|
54
|
+
function writeQuery(params, basePath, replace, w = safeWindow()) {
|
|
55
|
+
if (!w)
|
|
56
|
+
return;
|
|
57
|
+
const next = params.toString();
|
|
58
|
+
const href = `${basePath ?? w.location.pathname}${next ? `?${next}` : ''}${w.location.hash ?? ''}`;
|
|
59
|
+
if (replace)
|
|
60
|
+
w.history.replaceState(null, '', href);
|
|
61
|
+
else
|
|
62
|
+
w.history.pushState(null, '', href);
|
|
63
|
+
}
|
|
64
|
+
function readPathId(prefix, w = safeWindow()) {
|
|
65
|
+
if (!w || !prefix)
|
|
66
|
+
return null;
|
|
67
|
+
const base = ensureLeadingSlash(prefix);
|
|
68
|
+
const p = w.location.pathname;
|
|
69
|
+
if (p === base)
|
|
70
|
+
return null;
|
|
71
|
+
if (p.startsWith(base + '/')) {
|
|
72
|
+
const rest = p.slice((base + '/').length);
|
|
73
|
+
const seg = rest.split('/')[0];
|
|
74
|
+
return decodeURIComponent(seg);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function writePathId(id, prefix, replace, w = safeWindow()) {
|
|
79
|
+
if (!w || !prefix)
|
|
80
|
+
return;
|
|
81
|
+
const base = ensureLeadingSlash(prefix);
|
|
82
|
+
const path = id ? `${base}/${encodeURIComponent(id)}` : base;
|
|
83
|
+
const href = `${path}${w.location.search ?? ''}${w.location.hash ?? ''}`;
|
|
84
|
+
if (replace)
|
|
85
|
+
w.history.replaceState(null, '', href);
|
|
86
|
+
else
|
|
87
|
+
w.history.pushState(null, '', href);
|
|
88
|
+
}
|
|
89
|
+
function resolveRoute(manifest, opts) {
|
|
90
|
+
const mode = opts?.mode ?? manifest?.route?.mode ?? DEFAULT_ROUTE_MODE;
|
|
91
|
+
// Back-compat: support legacy `route.hashKey` and `route.path` from manifests.
|
|
92
|
+
const hashKey = opts?.hashKey ?? manifest?.route?.hashKey ?? DEFAULT_HASH_KEY;
|
|
93
|
+
const queryKey = opts?.queryKey ?? DEFAULT_QUERY_KEY;
|
|
94
|
+
const pathPrefix = opts?.pathPrefix ?? manifest?.route?.pathPrefix;
|
|
95
|
+
const basePath = opts?.path ??
|
|
96
|
+
manifest?.route?.path ?? // legacy base path for hash/query hrefs
|
|
97
|
+
undefined;
|
|
98
|
+
return { mode, hashKey, queryKey, pathPrefix, basePath };
|
|
99
|
+
}
|
|
100
|
+
function detectAutoMode(route, w = safeWindow()) {
|
|
101
|
+
if (!w)
|
|
102
|
+
return 'hash';
|
|
103
|
+
const hashParams = readHashParams(w);
|
|
104
|
+
if (hashParams.has(route.hashKey))
|
|
105
|
+
return 'hash';
|
|
106
|
+
const queryParams = readQueryParams(w);
|
|
107
|
+
if (queryParams.has(route.queryKey))
|
|
108
|
+
return 'query';
|
|
109
|
+
if (route.pathPrefix && readPathId(route.pathPrefix, w))
|
|
110
|
+
return 'path';
|
|
111
|
+
return 'hash';
|
|
112
|
+
}
|
|
113
|
+
// ── public: get/set/clear/href/subscribe (multi-mode) ───────────────────────
|
|
114
|
+
/** Read current pad id according to route options (defaults to auto). */
|
|
115
|
+
export function getActivePadId(manifest, opts) {
|
|
116
|
+
const route = resolveRoute(manifest, opts);
|
|
117
|
+
const mode = route.mode === 'auto' ? detectAutoMode(route) : route.mode;
|
|
118
|
+
if (mode === 'hash') {
|
|
119
|
+
const v = readHashParams().get(route.hashKey);
|
|
120
|
+
return v ?? null;
|
|
121
|
+
}
|
|
122
|
+
if (mode === 'query') {
|
|
123
|
+
const v = readQueryParams().get(route.queryKey);
|
|
124
|
+
return v ?? null;
|
|
125
|
+
}
|
|
126
|
+
// path
|
|
127
|
+
return readPathId(route.pathPrefix);
|
|
128
|
+
}
|
|
129
|
+
/** Set or clear current pad id (uses replaceState by default). */
|
|
130
|
+
export function setActivePadId(id, manifest, opts) {
|
|
131
|
+
const route = resolveRoute(manifest, opts);
|
|
132
|
+
const w = safeWindow();
|
|
133
|
+
if (!w)
|
|
134
|
+
return;
|
|
135
|
+
const replace = opts?.replace ?? true;
|
|
136
|
+
const mode = route.mode === 'auto' ? detectAutoMode(route, w) : route.mode;
|
|
137
|
+
if (mode === 'hash') {
|
|
138
|
+
const params = readHashParams(w);
|
|
139
|
+
if (id)
|
|
140
|
+
params.set(route.hashKey, id);
|
|
141
|
+
else
|
|
142
|
+
params.delete(route.hashKey);
|
|
143
|
+
writeHash(params, route.basePath, replace, w);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (mode === 'query') {
|
|
147
|
+
const params = readQueryParams(w);
|
|
148
|
+
if (id)
|
|
149
|
+
params.set(route.queryKey, id);
|
|
150
|
+
else
|
|
151
|
+
params.delete(route.queryKey);
|
|
152
|
+
writeQuery(params, route.basePath, replace, w);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
writePathId(id, route.pathPrefix, replace, w);
|
|
156
|
+
}
|
|
157
|
+
/** Remove any active pad indicator. */
|
|
158
|
+
export function clearActivePadId(manifest, opts) {
|
|
159
|
+
setActivePadId(null, manifest, opts);
|
|
160
|
+
}
|
|
161
|
+
/** Build an href that would navigate to a given pad id. */
|
|
162
|
+
export function hrefForPad(manifest, opts) {
|
|
163
|
+
const route = resolveRoute(manifest, opts);
|
|
164
|
+
const w = safeWindow();
|
|
165
|
+
const base = opts?.basePath ?? route.basePath ?? w?.location?.pathname ?? '/';
|
|
166
|
+
const mode = route.mode === 'auto' ? 'hash' : route.mode; // default hrefs to hash when auto
|
|
167
|
+
if (mode === 'hash') {
|
|
168
|
+
const params = w ? readHashParams(w) : new URLSearchParams();
|
|
169
|
+
params.set(route.hashKey, manifest.id);
|
|
170
|
+
const q = params.toString();
|
|
171
|
+
return q ? `${base}#${q}` : base;
|
|
172
|
+
}
|
|
173
|
+
if (mode === 'query') {
|
|
174
|
+
const params = w ? readQueryParams(w) : new URLSearchParams();
|
|
175
|
+
params.set(route.queryKey, manifest.id);
|
|
176
|
+
const q = params.toString();
|
|
177
|
+
const hash = w?.location?.hash ?? '';
|
|
178
|
+
return `${base}${q ? `?${q}` : ''}${hash}`;
|
|
179
|
+
}
|
|
180
|
+
// path
|
|
181
|
+
const prefix = ensureLeadingSlash(route.pathPrefix ?? '/pads');
|
|
182
|
+
return `${prefix}/${encodeURIComponent(manifest.id)}`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Subscribe to route changes (hashchange/popstate) and receive the current pad id.
|
|
186
|
+
* Returns an unsubscribe function.
|
|
187
|
+
*/
|
|
188
|
+
export function onPadRouteChange(fn, manifest, opts) {
|
|
189
|
+
const route = resolveRoute(manifest, opts);
|
|
190
|
+
const w = safeWindow();
|
|
191
|
+
if (!w)
|
|
192
|
+
return () => { };
|
|
193
|
+
const handler = () => fn(getActivePadId(manifest, opts));
|
|
194
|
+
const mode = route.mode === 'auto' ? 'auto' : route.mode;
|
|
195
|
+
const unsubs = [];
|
|
196
|
+
if (mode === 'hash' || mode === 'auto') {
|
|
197
|
+
w.addEventListener('hashchange', handler);
|
|
198
|
+
unsubs.push(() => w.removeEventListener('hashchange', handler));
|
|
199
|
+
}
|
|
200
|
+
if (mode === 'query' || mode === 'path' || mode === 'auto') {
|
|
201
|
+
w.addEventListener('popstate', handler);
|
|
202
|
+
unsubs.push(() => w.removeEventListener('popstate', handler));
|
|
203
|
+
}
|
|
204
|
+
if (opts?.fireNow)
|
|
205
|
+
handler();
|
|
206
|
+
return () => unsubs.forEach((u) => u());
|
|
207
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PadId, SceneId } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Scene Events (Pad Core)
|
|
4
|
+
* -------------------------------------------------------
|
|
5
|
+
* Typed CustomEvent helpers for entering and completing Scenes.
|
|
6
|
+
* Mirrors the pad events API shape (dispatch/on/... with unsubscribe).
|
|
7
|
+
*/
|
|
8
|
+
export declare const SCENE_ENTER: "scene:enter";
|
|
9
|
+
export declare const SCENE_COMPLETE: "scene:complete";
|
|
10
|
+
/** Who triggered the event and how the user got here */
|
|
11
|
+
export type SceneVia = 'diagram' | 'inspector' | 'deeplink' | 'system' | 'other';
|
|
12
|
+
export type SceneEnterDetail = {
|
|
13
|
+
padId: PadId | string;
|
|
14
|
+
sceneId: SceneId | string;
|
|
15
|
+
via?: SceneVia;
|
|
16
|
+
actorId?: string;
|
|
17
|
+
timestamp?: number;
|
|
18
|
+
};
|
|
19
|
+
export type SceneCompleteDetail = {
|
|
20
|
+
padId: PadId | string;
|
|
21
|
+
sceneId: SceneId | string;
|
|
22
|
+
result?: unknown;
|
|
23
|
+
success?: boolean;
|
|
24
|
+
actorId?: string;
|
|
25
|
+
timestamp?: number;
|
|
26
|
+
};
|
|
27
|
+
type SceneEnterEvent = CustomEvent<SceneEnterDetail>;
|
|
28
|
+
type SceneCompleteEvent = CustomEvent<SceneCompleteDetail>;
|
|
29
|
+
type DispatchOptions = {
|
|
30
|
+
skipRealtime?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Dispatchers
|
|
34
|
+
* -------------------------------------------------------
|
|
35
|
+
*/
|
|
36
|
+
export declare function dispatchSceneEnter(detail: SceneEnterDetail, target?: EventTarget | null, options?: DispatchOptions): void;
|
|
37
|
+
export declare function dispatchSceneComplete(detail: SceneCompleteDetail, target?: EventTarget | null, options?: DispatchOptions): void;
|
|
38
|
+
/**
|
|
39
|
+
* Listeners
|
|
40
|
+
* -------------------------------------------------------
|
|
41
|
+
* Returns an unsubscribe function for convenience.
|
|
42
|
+
*/
|
|
43
|
+
export declare function onSceneEnter(handler: (e: SceneEnterEvent) => void, target?: EventTarget | null): () => void;
|
|
44
|
+
export declare function onSceneComplete(handler: (e: SceneCompleteEvent) => void, target?: EventTarget | null): () => void;
|
|
45
|
+
/**
|
|
46
|
+
* Type re-exports (handy for consumers)
|
|
47
|
+
*/
|
|
48
|
+
export type { SceneEnterEvent, SceneCompleteEvent };
|