entangled-worlds-orbiters-shared 2.9.0 → 2.10.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/README.md +16 -0
- package/dist/capabilities/errors.d.ts +79 -0
- package/dist/capabilities/errors.js +87 -0
- package/dist/capabilities/index.d.ts +9 -0
- package/dist/capabilities/index.js +9 -0
- package/dist/capabilities/registry.d.ts +106 -0
- package/dist/capabilities/registry.js +215 -0
- package/dist/capabilities/schema.d.ts +52 -0
- package/dist/capabilities/schema.js +139 -0
- package/dist/studio/autosaveSession.d.ts +56 -0
- package/dist/studio/autosaveSession.js +138 -0
- package/dist/studio/headerBroadcast.d.ts +9 -0
- package/dist/studio/headerBroadcast.js +37 -0
- package/dist/studio/history.d.ts +37 -0
- package/dist/studio/history.js +50 -0
- package/dist/studio/hostTransport.d.ts +20 -0
- package/dist/studio/hostTransport.js +61 -0
- package/dist/studio/index.d.ts +6 -0
- package/dist/studio/index.js +9 -0
- package/dist/studio/patchStore.d.ts +7 -0
- package/dist/studio/patchStore.js +30 -0
- package/dist/studio/statusChannel.d.ts +12 -0
- package/dist/studio/statusChannel.js +14 -0
- package/dist/uploads/index.d.ts +34 -0
- package/dist/uploads/index.js +49 -0
- package/package.json +20 -4
- package/src/capabilities/errors.ts +115 -0
- package/src/capabilities/index.ts +22 -0
- package/src/capabilities/registry.ts +307 -0
- package/src/capabilities/schema.ts +164 -0
- package/src/loaders/cubeMapLoader.js +37 -15
- package/src/studio/autosaveSession.ts +186 -0
- package/src/studio/headerBroadcast.ts +52 -0
- package/src/studio/history.ts +91 -0
- package/src/studio/hostTransport.ts +70 -0
- package/src/studio/index.ts +30 -0
- package/src/studio/patchStore.ts +38 -0
- package/src/studio/statusChannel.ts +29 -0
- package/src/uploads/index.ts +93 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny JSON-Schema-subset validator (shared machinery, TypeScript).
|
|
3
|
+
*
|
|
4
|
+
* The ONE validator both registries use (supersedes the duplicated
|
|
5
|
+
* `studio/capabilities/schema.js` + `core/edit/capabilities/schema.ts`). Deliberately NOT a
|
|
6
|
+
* dependency (no zod/ajv) — the app is bundle-weight sensitive and the house style is
|
|
7
|
+
* lightweight guard clauses. Each capability carries an `inputSchema` literal in this subset;
|
|
8
|
+
* the SAME literal is what the future agent layer hands the model as a tool definition, so
|
|
9
|
+
* there is no second schema language to keep in sync.
|
|
10
|
+
*
|
|
11
|
+
* Supported keywords (a pragmatic subset of JSON Schema draft-07):
|
|
12
|
+
* type 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
|
13
|
+
* (or an array of those — value matches if it satisfies any)
|
|
14
|
+
* properties per-key subschemas (objects)
|
|
15
|
+
* required array of keys that must be present and not undefined (objects)
|
|
16
|
+
* items subschema applied to every array element
|
|
17
|
+
* enum value must be one of the listed values
|
|
18
|
+
* const value must deep-equal the given value
|
|
19
|
+
* nullable when true, null always passes regardless of type
|
|
20
|
+
* minimum / maximum numbers
|
|
21
|
+
* minLength / maxLength strings
|
|
22
|
+
* minItems / maxItems arrays
|
|
23
|
+
* pattern string regex source (tested with RegExp)
|
|
24
|
+
*
|
|
25
|
+
* A schema with no `type` accepts any value (still honouring enum/const/properties if set).
|
|
26
|
+
* Unknown keywords are ignored — forward-compatible, never throws on shape it doesn't know.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ValidationError, type SchemaIssue } from './errors.js'
|
|
30
|
+
|
|
31
|
+
export type JsonType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
|
32
|
+
|
|
33
|
+
export interface JsonSchema {
|
|
34
|
+
type?: JsonType | JsonType[]
|
|
35
|
+
properties?: Record<string, JsonSchema>
|
|
36
|
+
required?: string[]
|
|
37
|
+
items?: JsonSchema
|
|
38
|
+
enum?: unknown[]
|
|
39
|
+
const?: unknown
|
|
40
|
+
nullable?: boolean
|
|
41
|
+
minimum?: number
|
|
42
|
+
maximum?: number
|
|
43
|
+
minLength?: number
|
|
44
|
+
maxLength?: number
|
|
45
|
+
minItems?: number
|
|
46
|
+
maxItems?: number
|
|
47
|
+
pattern?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TYPE_CHECKS: Record<JsonType, (v: unknown) => boolean> = {
|
|
51
|
+
object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
|
|
52
|
+
array: (v) => Array.isArray(v),
|
|
53
|
+
string: (v) => typeof v === 'string',
|
|
54
|
+
number: (v) => typeof v === 'number' && Number.isFinite(v),
|
|
55
|
+
integer: (v) => typeof v === 'number' && Number.isInteger(v),
|
|
56
|
+
boolean: (v) => typeof v === 'boolean',
|
|
57
|
+
null: (v) => v === null,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
61
|
+
if (a === b) return true
|
|
62
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false
|
|
63
|
+
const ka = Object.keys(a as object)
|
|
64
|
+
const kb = Object.keys(b as object)
|
|
65
|
+
if (ka.length !== kb.length) return false
|
|
66
|
+
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkType(schema: JsonSchema, value: unknown): boolean {
|
|
70
|
+
if (schema.type == null) return true
|
|
71
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
72
|
+
return types.some((t) => (TYPE_CHECKS[t] ? TYPE_CHECKS[t](value) : true))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeType(value: unknown): string {
|
|
76
|
+
if (value === null) return 'null'
|
|
77
|
+
if (Array.isArray(value)) return 'array'
|
|
78
|
+
return typeof value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateNode(schema: JsonSchema | undefined, value: unknown, path: string, issues: SchemaIssue[]): void {
|
|
82
|
+
if (!schema || typeof schema !== 'object') return
|
|
83
|
+
const at = path || '(root)'
|
|
84
|
+
|
|
85
|
+
if (schema.nullable && value === null) return
|
|
86
|
+
|
|
87
|
+
if (!checkType(schema, value)) {
|
|
88
|
+
const want = Array.isArray(schema.type) ? schema.type.join('|') : schema.type
|
|
89
|
+
issues.push({ path: at, message: `expected type ${want}, got ${describeType(value)}` })
|
|
90
|
+
return // type is the gate; downstream checks would be noise
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if ('const' in schema && !deepEqual(value, schema.const)) {
|
|
94
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((e) => deepEqual(e, value))) {
|
|
98
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof value === 'number') {
|
|
102
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
103
|
+
issues.push({ path: at, message: `must be >= ${schema.minimum}` })
|
|
104
|
+
}
|
|
105
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
106
|
+
issues.push({ path: at, message: `must be <= ${schema.maximum}` })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof value === 'string') {
|
|
111
|
+
if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
|
|
112
|
+
issues.push({ path: at, message: `must be at least ${schema.minLength} chars` })
|
|
113
|
+
}
|
|
114
|
+
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
|
|
115
|
+
issues.push({ path: at, message: `must be at most ${schema.maxLength} chars` })
|
|
116
|
+
}
|
|
117
|
+
if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value)) {
|
|
118
|
+
issues.push({ path: at, message: `must match /${schema.pattern}/` })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(value)) {
|
|
123
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
124
|
+
issues.push({ path: at, message: `must have at least ${schema.minItems} items` })
|
|
125
|
+
}
|
|
126
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
127
|
+
issues.push({ path: at, message: `must have at most ${schema.maxItems} items` })
|
|
128
|
+
}
|
|
129
|
+
if (schema.items) {
|
|
130
|
+
value.forEach((el, i) => validateNode(schema.items, el, `${path}[${i}]`, issues))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (TYPE_CHECKS.object(value)) {
|
|
135
|
+
const obj = value as Record<string, unknown>
|
|
136
|
+
if (Array.isArray(schema.required)) {
|
|
137
|
+
for (const key of schema.required) {
|
|
138
|
+
if (obj[key] === undefined) issues.push({ path: path ? `${path}.${key}` : key, message: 'is required' })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (schema.properties) {
|
|
142
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
143
|
+
if (obj[key] !== undefined) validateNode(sub, obj[key], path ? `${path}.${key}` : key, issues)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Returns { ok, issues }. Never throws. */
|
|
150
|
+
export function validate(schema: JsonSchema, value: unknown): { ok: boolean; issues: SchemaIssue[] } {
|
|
151
|
+
const issues: SchemaIssue[] = []
|
|
152
|
+
validateNode(schema ?? {}, value, '', issues)
|
|
153
|
+
return { ok: issues.length === 0, issues }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Throws ValidationError when invalid; returns the value otherwise. Used by the registry. */
|
|
157
|
+
export function assertValid<T>(schema: JsonSchema, value: T, capability: string | null = null): T {
|
|
158
|
+
const { ok, issues } = validate(schema, value)
|
|
159
|
+
if (!ok) {
|
|
160
|
+
const summary = issues.map((i) => `${i.path}: ${i.message}`).join('; ')
|
|
161
|
+
throw new ValidationError(`Invalid input${capability ? ` for "${capability}"` : ''}: ${summary}`, { capability, issues })
|
|
162
|
+
}
|
|
163
|
+
return value
|
|
164
|
+
}
|
|
@@ -56,25 +56,47 @@ function detectExtension(faceKeys) {
|
|
|
56
56
|
return 'ktx2'; // default assumption
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// How many faces may be in flight at once. Not six: the KTX2 worker pool three.js gives us holds
|
|
60
|
+
// four transcoder workers, each carrying its own copy of the basis wasm, and that same pool also
|
|
61
|
+
// serves body textures and GLBs. Asking for all six together spawned every worker and queued the
|
|
62
|
+
// other loaders behind the whole sky; measured on cold fetches it was no faster than three.
|
|
63
|
+
const FACES_IN_FLIGHT = 3;
|
|
64
|
+
|
|
59
65
|
// Load KTX2 faces and create skybox mesh
|
|
60
66
|
async function loadFacesKTX2(faceKeys, renderer) {
|
|
61
67
|
const loader = getKTX2LoaderForRenderer(renderer);
|
|
68
|
+
const anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
|
|
69
|
+
|
|
70
|
+
const loadFace = async face => {
|
|
71
|
+
const tex = await loader.loadAsync(faceKeys[face]);
|
|
72
|
+
tex.flipY = false;
|
|
73
|
+
tex.wrapS = THREE.ClampToEdgeWrapping;
|
|
74
|
+
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
75
|
+
tex.anisotropy = anisotropy;
|
|
76
|
+
return { face, tex };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Faces overlap instead of chaining. Awaiting each one in turn made every face wait on the
|
|
80
|
+
// previous face's download *and* its transcode, so the sky arrived six round trips deep.
|
|
81
|
+
const settled = [];
|
|
82
|
+
for (let i = 0; i < FACE_ORDER.length; i += FACES_IN_FLIGHT) {
|
|
83
|
+
const wave = FACE_ORDER.slice(i, i + FACES_IN_FLIGHT).map(face => loadFace(face));
|
|
84
|
+
settled.push(...await Promise.allSettled(wave));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// allSettled, not all: a bare Promise.all rejects on the first failure and leaks the faces that
|
|
88
|
+
// land after it. Settle every face first, then dispose everything that arrived before rethrowing.
|
|
62
89
|
const loaded = [];
|
|
63
|
-
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
} catch (e) {
|
|
74
|
-
// Dispose already loaded textures on failure
|
|
75
|
-
loaded.forEach(f => f.tex.dispose?.());
|
|
76
|
-
throw e;
|
|
77
|
-
}
|
|
90
|
+
let failure = null;
|
|
91
|
+
for (const result of settled) {
|
|
92
|
+
if (result.status === 'fulfilled') loaded.push(result.value);
|
|
93
|
+
else if (!failure) failure = result.reason;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (failure) {
|
|
97
|
+
// Dispose already loaded textures on failure
|
|
98
|
+
loaded.forEach(f => f.tex.dispose?.());
|
|
99
|
+
throw failure;
|
|
78
100
|
}
|
|
79
101
|
|
|
80
102
|
const size = 500;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// AutosaveSession — the single owner of a studio's autosave state slice.
|
|
2
|
+
//
|
|
3
|
+
// Owns STATE TRANSITIONS only: the debounce timer and the
|
|
4
|
+
// dirty/inFlight/queued/lastSavedDraftKey/pendingHost* transitions. The real I/O (building the
|
|
5
|
+
// draft, the API call, the host postMessage, toasts, analytics) stays in the app's host bridge,
|
|
6
|
+
// which drives this session through methods and never writes the slice directly. No DOM: the
|
|
7
|
+
// header save-status dot is rendered through the injected `setHeaderStatus` callback.
|
|
8
|
+
//
|
|
9
|
+
// Convention: a `*Session` owns a mutable state slice (no DOM); `createX(holder, deps)`.
|
|
10
|
+
|
|
11
|
+
export type AutosaveTone = 'default' | 'busy' | 'warn' | 'ok' | 'error'
|
|
12
|
+
|
|
13
|
+
export interface AutosaveState {
|
|
14
|
+
ready: boolean
|
|
15
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
16
|
+
dirty: boolean
|
|
17
|
+
inFlight: boolean
|
|
18
|
+
queued: boolean
|
|
19
|
+
lastSavedDraftKey: string
|
|
20
|
+
pendingHostDraftKey: string
|
|
21
|
+
pendingHostDraftManual: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AutosaveCtrl {
|
|
25
|
+
autosave: AutosaveState
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AutosaveDeps {
|
|
29
|
+
// Persist the draft now — the debounce timer's trigger. Late-bound: it may close over a host
|
|
30
|
+
// bridge built after the session, since it only ever runs inside setTimeout.
|
|
31
|
+
flushAutosave?: () => unknown
|
|
32
|
+
// Render the header save-status chip ({ tone } to show, null to clear). The session never
|
|
33
|
+
// touches the DOM/shell directly — this is the one presentation seam.
|
|
34
|
+
setHeaderStatus?: (arg: { tone: AutosaveTone } | null) => void
|
|
35
|
+
// Return true while a save would be rejected (e.g. a server-side lock is held) — the timer
|
|
36
|
+
// reschedules instead of flushing until it clears.
|
|
37
|
+
shouldDefer?: () => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AutosaveSession {
|
|
41
|
+
markDirty(): void
|
|
42
|
+
schedule(opts?: { delayMs?: number }): void
|
|
43
|
+
clearTimer(): void
|
|
44
|
+
syncStatus(): void
|
|
45
|
+
setStatus(text?: string, tone?: AutosaveTone): void
|
|
46
|
+
arm(baselineKey: string): void
|
|
47
|
+
// Save round-trip (driven by the app's host bridge save). Split in two so a blocked autosave
|
|
48
|
+
// tick (not-ready / not-dirty / inFlight) returns before the descriptor is ever built.
|
|
49
|
+
tryBegin(opts: { manual: boolean }): boolean
|
|
50
|
+
checkDedupAndBegin(opts: { manual: boolean; currentDraftKey: string }): { proceed: boolean; result: boolean }
|
|
51
|
+
markSaved(draftKey: string): void
|
|
52
|
+
beginHostAck(opts: { manual: boolean; currentDraftKey: string }): void
|
|
53
|
+
settleAfterApi(opts: { manual: boolean }): void
|
|
54
|
+
// Host-ack is two calls so the caller can interleave its status/toast/
|
|
55
|
+
// analytics between the state resolution and the final settle — preserving the exact order
|
|
56
|
+
// of the original handler (where the transient 'Save failed' header is then re-synced).
|
|
57
|
+
resolveHostAck(opts: { ok: boolean }): { wasManual: boolean }
|
|
58
|
+
settleHostAck(): void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAutosaveSession(ctrl: AutosaveCtrl, deps: AutosaveDeps = {}): AutosaveSession {
|
|
62
|
+
ctrl.autosave = {
|
|
63
|
+
ready: false,
|
|
64
|
+
timer: null,
|
|
65
|
+
dirty: false,
|
|
66
|
+
inFlight: false,
|
|
67
|
+
queued: false,
|
|
68
|
+
lastSavedDraftKey: '',
|
|
69
|
+
pendingHostDraftKey: '',
|
|
70
|
+
pendingHostDraftManual: false,
|
|
71
|
+
}
|
|
72
|
+
const state = ctrl.autosave
|
|
73
|
+
|
|
74
|
+
const api: AutosaveSession = {
|
|
75
|
+
setStatus(text = '', tone = 'default') {
|
|
76
|
+
deps.setHeaderStatus?.(text ? { tone } : null)
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
syncStatus() {
|
|
80
|
+
if (state.inFlight) { api.setStatus('Saving...', 'busy'); return }
|
|
81
|
+
if (state.dirty) { api.setStatus('Unsaved changes', 'warn'); return }
|
|
82
|
+
if (state.lastSavedDraftKey) { api.setStatus('All changes saved', 'ok'); return }
|
|
83
|
+
api.setStatus('')
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
clearTimer() {
|
|
87
|
+
if (!state.timer) return
|
|
88
|
+
clearTimeout(state.timer)
|
|
89
|
+
state.timer = null
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
schedule({ delayMs = 2000 } = {}) {
|
|
93
|
+
if (!state.ready) return
|
|
94
|
+
api.clearTimer()
|
|
95
|
+
if (!state.dirty) { api.syncStatus(); return }
|
|
96
|
+
api.syncStatus()
|
|
97
|
+
state.timer = setTimeout(() => {
|
|
98
|
+
state.timer = null
|
|
99
|
+
// Reschedule (don't flush) while the app says a save would be rejected.
|
|
100
|
+
if (deps.shouldDefer?.()) {
|
|
101
|
+
api.schedule({ delayMs: 2000 })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
void deps.flushAutosave?.()
|
|
105
|
+
}, delayMs)
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
markDirty() {
|
|
109
|
+
if (!state.ready) return
|
|
110
|
+
state.dirty = true
|
|
111
|
+
if (state.inFlight) {
|
|
112
|
+
state.queued = true
|
|
113
|
+
api.syncStatus()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
api.schedule()
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
arm(baselineKey: string) {
|
|
120
|
+
state.lastSavedDraftKey = baselineKey
|
|
121
|
+
state.ready = true
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
tryBegin({ manual }) {
|
|
125
|
+
api.clearTimer()
|
|
126
|
+
if (!manual && (!state.ready || !state.dirty)) return false
|
|
127
|
+
if (state.inFlight) {
|
|
128
|
+
state.queued = true
|
|
129
|
+
api.syncStatus()
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
return true
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
checkDedupAndBegin({ manual, currentDraftKey }) {
|
|
136
|
+
if (!manual && currentDraftKey && currentDraftKey === state.lastSavedDraftKey) {
|
|
137
|
+
state.dirty = false
|
|
138
|
+
api.syncStatus()
|
|
139
|
+
return { proceed: false, result: true }
|
|
140
|
+
}
|
|
141
|
+
state.inFlight = true
|
|
142
|
+
api.syncStatus()
|
|
143
|
+
return { proceed: true, result: false }
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
markSaved(draftKey: string) {
|
|
147
|
+
// An edit that landed while this save was in flight is queued and NOT in what was saved —
|
|
148
|
+
// stay dirty so the queued reschedule writes it.
|
|
149
|
+
state.dirty = state.queued
|
|
150
|
+
state.lastSavedDraftKey = draftKey
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
beginHostAck({ manual, currentDraftKey }) {
|
|
154
|
+
state.pendingHostDraftKey = currentDraftKey
|
|
155
|
+
state.pendingHostDraftManual = manual === true
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
settleAfterApi({ manual }) {
|
|
159
|
+
state.inFlight = false
|
|
160
|
+
api.syncStatus()
|
|
161
|
+
if (state.queued) {
|
|
162
|
+
state.queued = false
|
|
163
|
+
api.schedule({ delayMs: manual ? 300 : 1200 })
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
resolveHostAck({ ok }) {
|
|
168
|
+
const wasManual = state.pendingHostDraftManual === true
|
|
169
|
+
if (ok) api.markSaved(state.pendingHostDraftKey || state.lastSavedDraftKey)
|
|
170
|
+
return { wasManual }
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
settleHostAck() {
|
|
174
|
+
state.pendingHostDraftKey = ''
|
|
175
|
+
state.pendingHostDraftManual = false
|
|
176
|
+
state.inFlight = false
|
|
177
|
+
api.syncStatus()
|
|
178
|
+
if (state.queued) {
|
|
179
|
+
state.queued = false
|
|
180
|
+
api.schedule({ delayMs: 1200 })
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return api
|
|
186
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Header-protocol broadcast (the "header sink" seam).
|
|
2
|
+
//
|
|
3
|
+
// A studio controller drives its header imperatively (`shell.setUndoRedoState(...)`,
|
|
4
|
+
// `shell.setHeaderStatus(...)`, …). When a React header mounts over the stage it must show the
|
|
5
|
+
// SAME state, driven by the SAME controller logic — no duplicated per-step rules, one source of
|
|
6
|
+
// truth.
|
|
7
|
+
//
|
|
8
|
+
// `makeHeaderBroadcast` wraps the shell so every listed header setter ALSO fans out to a sink
|
|
9
|
+
// (read live through `getSink`, so a late registration is picked up) and retains its last value,
|
|
10
|
+
// so a sink that registers after the shell was driven can be replayed race-free via `replayInto`.
|
|
11
|
+
// Every other member of the shell passes through untouched.
|
|
12
|
+
|
|
13
|
+
type Setter = (arg: never) => void
|
|
14
|
+
|
|
15
|
+
/** A header sink implements any subset of the setters. */
|
|
16
|
+
export type HeaderSinkOf<K extends string> = Partial<Record<K, Setter>>
|
|
17
|
+
|
|
18
|
+
export type HeaderBroadcast<Shell extends object, K extends string> = Shell & {
|
|
19
|
+
/** Push the last value of every header setter into a freshly registered sink. */
|
|
20
|
+
replayInto(sink: HeaderSinkOf<K> | null | undefined): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function makeHeaderBroadcast<Shell extends object, K extends string>(
|
|
24
|
+
shell: Shell,
|
|
25
|
+
getSink: () => HeaderSinkOf<K> | null | undefined,
|
|
26
|
+
setters: readonly K[],
|
|
27
|
+
): HeaderBroadcast<Shell, K> {
|
|
28
|
+
// One slot per setter (all single-arg, last write wins) is the full retained header model.
|
|
29
|
+
const lastArg = new Map<K, unknown>()
|
|
30
|
+
const call = (fn: unknown, self: unknown, arg: unknown) => {
|
|
31
|
+
if (typeof fn === 'function') fn.call(self, arg)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Spread carries the shell's own members (data props and non-header methods). Shell methods
|
|
35
|
+
// are expected to be closures (no `this`), so copying the references is safe.
|
|
36
|
+
const wrapper = { ...shell } as Record<string, unknown>
|
|
37
|
+
for (const key of setters) {
|
|
38
|
+
const original = (shell as Record<string, unknown>)[key]
|
|
39
|
+
wrapper[key] = (arg: unknown) => {
|
|
40
|
+
call(original, shell, arg)
|
|
41
|
+
lastArg.set(key, arg)
|
|
42
|
+
call(getSink()?.[key], undefined, arg)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
wrapper.replayInto = (sink: HeaderSinkOf<K> | null | undefined) => {
|
|
47
|
+
if (!sink) return
|
|
48
|
+
for (const [key, arg] of lastArg) call(sink[key], undefined, arg)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return wrapper as HeaderBroadcast<Shell, K>
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Snapshot-based undo/redo for a studio.
|
|
2
|
+
//
|
|
3
|
+
// The history knows nothing about what it stores: the app injects `snapshot()` (capture the
|
|
4
|
+
// editable state, deep-cloned) and `restore(s)` (put a snapshot back). `onChange` fires after every
|
|
5
|
+
// push/undo/redo/clear so the app can mark its draft dirty, rebuild derived views, and refresh the
|
|
6
|
+
// header's undo/redo buttons. The stacks can be supplied by the caller so an existing controller
|
|
7
|
+
// that already exposes `history.undo` / `history.redo` arrays keeps reading the same arrays.
|
|
8
|
+
|
|
9
|
+
export interface HistoryStacks<S> {
|
|
10
|
+
undo: S[]
|
|
11
|
+
redo: S[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type HistoryAction = 'push' | 'undo' | 'redo' | 'clear'
|
|
15
|
+
|
|
16
|
+
export interface HistoryState {
|
|
17
|
+
canUndo: boolean
|
|
18
|
+
canRedo: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HistoryDeps<S> {
|
|
22
|
+
/** Capture the current editable state. Must return a detached copy (deep clone). */
|
|
23
|
+
snapshot: () => S
|
|
24
|
+
/** Put a previously captured snapshot back as the current state. */
|
|
25
|
+
restore: (snapshot: S) => void
|
|
26
|
+
/** Return false to skip a push (e.g. nothing is loaded yet). */
|
|
27
|
+
canPush?: () => boolean
|
|
28
|
+
/** Oldest entries drop off beyond this many undo steps. */
|
|
29
|
+
maxHistory?: number
|
|
30
|
+
/** Reuse existing stacks instead of fresh ones. */
|
|
31
|
+
stacks?: HistoryStacks<S>
|
|
32
|
+
/** Fires after every change of the stacks, with the action that caused it. */
|
|
33
|
+
onChange?: (state: HistoryState, action: HistoryAction) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StudioHistory<S> {
|
|
37
|
+
readonly stacks: HistoryStacks<S>
|
|
38
|
+
/** Record the current state as an undo step. Call BEFORE applying a reversible edit. */
|
|
39
|
+
push(): void
|
|
40
|
+
undo(): boolean
|
|
41
|
+
redo(): boolean
|
|
42
|
+
clear(): void
|
|
43
|
+
canUndo(): boolean
|
|
44
|
+
canRedo(): boolean
|
|
45
|
+
getState(): HistoryState
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHistory<S>(deps: HistoryDeps<S>): StudioHistory<S> {
|
|
49
|
+
const { snapshot, restore, canPush, maxHistory = 60, onChange } = deps
|
|
50
|
+
const stacks: HistoryStacks<S> = deps.stacks ?? { undo: [], redo: [] }
|
|
51
|
+
|
|
52
|
+
const getState = (): HistoryState => ({
|
|
53
|
+
canUndo: stacks.undo.length > 0,
|
|
54
|
+
canRedo: stacks.redo.length > 0,
|
|
55
|
+
})
|
|
56
|
+
const notify = (action: HistoryAction) => onChange?.(getState(), action)
|
|
57
|
+
|
|
58
|
+
function step(from: S[], to: S[], action: 'undo' | 'redo'): boolean {
|
|
59
|
+
if (!from.length) return false
|
|
60
|
+
to.push(snapshot())
|
|
61
|
+
restore(from.pop() as S)
|
|
62
|
+
notify(action)
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
stacks,
|
|
68
|
+
push() {
|
|
69
|
+
if (canPush && !canPush()) return
|
|
70
|
+
stacks.undo.push(snapshot())
|
|
71
|
+
if (stacks.undo.length > maxHistory) stacks.undo.shift()
|
|
72
|
+
stacks.redo.length = 0
|
|
73
|
+
notify('push')
|
|
74
|
+
},
|
|
75
|
+
undo: () => step(stacks.undo, stacks.redo, 'undo'),
|
|
76
|
+
redo: () => step(stacks.redo, stacks.undo, 'redo'),
|
|
77
|
+
clear() {
|
|
78
|
+
stacks.undo.length = 0
|
|
79
|
+
stacks.redo.length = 0
|
|
80
|
+
notify('clear')
|
|
81
|
+
},
|
|
82
|
+
canUndo: () => stacks.undo.length > 0,
|
|
83
|
+
canRedo: () => stacks.redo.length > 0,
|
|
84
|
+
getState,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deep clone for JSON-shaped studio state — the usual `snapshot()` building block. */
|
|
89
|
+
export function cloneJson<T>(value: T): T {
|
|
90
|
+
return value == null ? value : (JSON.parse(JSON.stringify(value)) as T)
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The generic half of a studio's host bridge: who may act as the host, and how to talk to it.
|
|
2
|
+
//
|
|
3
|
+
// A studio runs embedded in an iframe of the platform dashboard (or a local dev server). Drafts
|
|
4
|
+
// and captured media are posted back to whichever origin claims the host role, so an arbitrary
|
|
5
|
+
// embedding page must never pass the trust gate, and nothing is ever broadcast to '*' except the
|
|
6
|
+
// initial "ready" ping (which carries no payload).
|
|
7
|
+
//
|
|
8
|
+
// The app-specific half — what a draft looks like, which API saves it, which message types exist
|
|
9
|
+
// — stays in each app and drives an AutosaveSession.
|
|
10
|
+
|
|
11
|
+
interface WindowLike {
|
|
12
|
+
location: { origin: string }
|
|
13
|
+
parent: WindowLike | null
|
|
14
|
+
postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRUSTED_DEV_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
|
|
18
|
+
const TRUSTED_DOMAIN = 'plantasia.space'
|
|
19
|
+
|
|
20
|
+
const currentWindow = (): WindowLike | null =>
|
|
21
|
+
typeof window === 'undefined' ? null : (window as unknown as WindowLike)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True for the studio's own origin, a local dev host, or an https plantasia.space origin
|
|
25
|
+
* (apex or any subdomain).
|
|
26
|
+
*/
|
|
27
|
+
export function isTrustedHostOrigin(origin: string | null | undefined, win = currentWindow()): boolean {
|
|
28
|
+
if (!origin || origin === 'null') return false
|
|
29
|
+
let url: URL
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(origin)
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
if (win && url.origin === win.location.origin) return true
|
|
36
|
+
const hostname = url.hostname.toLowerCase()
|
|
37
|
+
if (TRUSTED_DEV_HOSTNAMES.has(hostname)) return true
|
|
38
|
+
return url.protocol === 'https:' && (hostname === TRUSTED_DOMAIN || hostname.endsWith(`.${TRUSTED_DOMAIN}`))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when running inside a parent frame. */
|
|
42
|
+
export function isEmbedded(win = currentWindow()): boolean {
|
|
43
|
+
try {
|
|
44
|
+
return Boolean(win?.parent && win.parent !== win)
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Post to the parent frame. Returns false (and sends nothing) when there is no recorded host
|
|
52
|
+
* origin or no parent — never falls back to a '*' broadcast for payloads.
|
|
53
|
+
*/
|
|
54
|
+
export function postToHost(
|
|
55
|
+
message: unknown,
|
|
56
|
+
targetOrigin: string | null | undefined,
|
|
57
|
+
transfer: Transferable[] = [],
|
|
58
|
+
win = currentWindow(),
|
|
59
|
+
): boolean {
|
|
60
|
+
try {
|
|
61
|
+
if (!targetOrigin || !win) return false
|
|
62
|
+
if (isEmbedded(win)) {
|
|
63
|
+
;(win.parent as WindowLike).postMessage(message, targetOrigin, transfer)
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn('[Studio] postMessage to host failed', err)
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Studio session machinery shared by the Entangled World and Orbiter studios: undo/redo, the
|
|
2
|
+
// autosave state machine, the header mirror (patch store + broadcast/replay), the status line
|
|
3
|
+
// seam, and the host-frame transport. Framework-free: no React, no DOM beyond postMessage.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
createHistory,
|
|
7
|
+
cloneJson,
|
|
8
|
+
type StudioHistory,
|
|
9
|
+
type HistoryDeps,
|
|
10
|
+
type HistoryStacks,
|
|
11
|
+
type HistoryState,
|
|
12
|
+
type HistoryAction,
|
|
13
|
+
} from './history.js'
|
|
14
|
+
export {
|
|
15
|
+
createAutosaveSession,
|
|
16
|
+
type AutosaveSession,
|
|
17
|
+
type AutosaveState,
|
|
18
|
+
type AutosaveCtrl,
|
|
19
|
+
type AutosaveDeps,
|
|
20
|
+
type AutosaveTone,
|
|
21
|
+
} from './autosaveSession.js'
|
|
22
|
+
export { createPatchStore, type PatchStore } from './patchStore.js'
|
|
23
|
+
export { makeHeaderBroadcast, type HeaderBroadcast, type HeaderSinkOf } from './headerBroadcast.js'
|
|
24
|
+
export {
|
|
25
|
+
createStatusChannel,
|
|
26
|
+
type StatusChannel,
|
|
27
|
+
type StatusSink,
|
|
28
|
+
type StatusShowOptions,
|
|
29
|
+
} from './statusChannel.js'
|
|
30
|
+
export { isTrustedHostOrigin, isEmbedded, postToHost } from './hostTransport.js'
|