@symbo.ls/sync 3.8.9 → 3.14.1

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.
@@ -51,8 +51,9 @@ export const connectedToSymbols = (clients, element, state) => {
51
51
 
52
52
  const t = setTimeout(() => {
53
53
  delete state.notifications.connected
54
- element.notifications.content.connected
55
- .setProps({ animation: 'fadeOutDown' })
54
+ element.notifications.content.connected.update({
55
+ animation: 'fadeOutDown'
56
+ })
56
57
  state.update({ connected: true })
57
58
  clearTimeout(t)
58
59
  }, 3000)
@@ -69,8 +70,9 @@ export const connectedToSymbols = (clients, element, state) => {
69
70
  const t = setTimeout(() => {
70
71
  delete state.notifications.connected
71
72
  if (element.notifications.content.connected) {
72
- element.notifications.content.connected
73
- .setProps({ animation: 'fadeOutDown' })
73
+ element.notifications.content.connected.update({
74
+ animation: 'fadeOutDown'
75
+ })
74
76
  }
75
77
  state.update({ connected: true })
76
78
  clearTimeout(t)
@@ -103,11 +105,11 @@ export const Notifications = {
103
105
  }
104
106
  },
105
107
  onRender: (e, el, s) => {
106
- el.setProps({ animation: 'fadeInUp' })
108
+ el.update({ animation: 'fadeInUp' })
107
109
  },
108
110
  onClick: (e, el, s) => {
109
111
  delete s.notifications[el.key]
110
- el.setProps({ animation: 'fadeOutDown' })
112
+ el.update({ animation: 'fadeOutDown' })
111
113
  if (s.onClose) s.onClose(e, el, s)
112
114
  }
113
115
  }),
package/applier.js ADDED
@@ -0,0 +1,71 @@
1
+ // Pure ops applier — Node-safe, zero DOM coupling. Both browser handlers
2
+ // (onSnapshot/onOps in `./handlers.js`) and server-side test code import
3
+ // from here. Anything DOM-touching (init(designSystem), s.update(...)
4
+ // re-renders, el.call('router', …)) lives in handlers.js.
5
+
6
+ // Forbidden path segments — block prototype-pollution writes coming over
7
+ // any sync transport. Without the filter, a remote
8
+ // `setPath(ctx, ['__proto__', 'isAdmin'], true)` would pollute
9
+ // Object.prototype across the whole runtime.
10
+ export const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
11
+
12
+ export const isSafePath = (path) => {
13
+ if (!Array.isArray(path)) return false
14
+ for (let i = 0; i < path.length; i++) {
15
+ if (FORBIDDEN_SEGMENTS.has(path[i])) return false
16
+ }
17
+ return true
18
+ }
19
+
20
+ export const deletePath = (obj, path) => {
21
+ if (!obj || !isSafePath(path)) return
22
+ path.reduce((acc, v, i, arr) => {
23
+ if (acc && v in acc) {
24
+ if (i !== arr.length - 1) return acc[v]
25
+ delete acc[v]
26
+ }
27
+ return void 0
28
+ }, obj)
29
+ }
30
+
31
+ export const setPath = (obj, path, value, createNestedObjects = false) => {
32
+ if (!obj || !isSafePath(path)) return
33
+ path.reduce((acc, v, i, arr) => {
34
+ if (!acc) return void 0
35
+ if (i !== arr.length - 1) {
36
+ if (!acc[v] && createNestedObjects) acc[v] = {}
37
+ return acc[v]
38
+ }
39
+ acc[v] = value
40
+ return void 0
41
+ }, obj)
42
+ }
43
+
44
+ /**
45
+ * Apply a batch of `[action, path, value]` ops to a context object.
46
+ * Returns the set of top-level keys that changed so the caller can decide
47
+ * which downstream re-render side-effects to run.
48
+ *
49
+ * Supported actions: 'set', 'update' (alias for set), 'delete'.
50
+ * Empty paths are skipped — replace at the root needs a snapshot, not an op.
51
+ */
52
+ export const applyOpsToCtx = (ctx, changes) => {
53
+ const topLevelChanged = new Set()
54
+ if (!Array.isArray(changes)) return topLevelChanged
55
+ for (const [action, path, change] of changes) {
56
+ if (!Array.isArray(path) || !path.length) continue
57
+ topLevelChanged.add(path[0])
58
+ switch (action) {
59
+ case 'delete':
60
+ deletePath(ctx, path)
61
+ break
62
+ case 'update':
63
+ case 'set':
64
+ setPath(ctx, path, change, true)
65
+ break
66
+ default:
67
+ break
68
+ }
69
+ }
70
+ return topLevelChanged
71
+ }
package/handlers.js ADDED
@@ -0,0 +1,83 @@
1
+ // Browser-only sync handlers. Imports `init` from smbls (which transitively
2
+ // loads scratch and touches `document`), so this module must not be imported
3
+ // from server-side code paths. Server-side tests/utilities should import from
4
+ // `./applier.js` instead — that's the pure ops layer.
5
+
6
+ import { init } from 'smbls/src/init.js'
7
+ import { overwriteShallow } from '@symbo.ls/utils'
8
+ import { applyOpsToCtx } from './applier.js'
9
+
10
+ export const onSnapshot =
11
+ (el, s, ctx) =>
12
+ (payload = {}) => {
13
+ let { data } = payload
14
+ const { schema } = payload
15
+ if (!data) return
16
+
17
+ data = el.call(
18
+ 'deepDestringifyFunctions',
19
+ data,
20
+ Array.isArray(data) ? [] : {}
21
+ )
22
+
23
+ Object.entries(data).forEach(([key, val]) => {
24
+ if (ctx[key] && typeof ctx[key] === 'object') {
25
+ if (key === 'designSystem') {
26
+ init(val)
27
+ } else {
28
+ overwriteShallow(ctx[key], val)
29
+ }
30
+ } else {
31
+ ctx[key] = val
32
+ }
33
+ })
34
+
35
+ if (schema) ctx.schema = schema
36
+ }
37
+
38
+ export const onOps =
39
+ (el, s, ctx) =>
40
+ (payload = {}) => {
41
+ let { changes } = payload
42
+ if (!changes || !Array.isArray(changes) || !changes.length) return
43
+
44
+ changes = el.call(
45
+ 'deepDestringifyFunctions',
46
+ changes,
47
+ Array.isArray(changes) ? [] : {}
48
+ )
49
+
50
+ const changed = applyOpsToCtx(ctx, changes)
51
+
52
+ // State changes → re-run state. Editor-driven navigation now goes
53
+ // through explicit `el.router(route)` calls (or a dedicated `navigate`
54
+ // event) instead of writing `state.route` as a side-effect channel.
55
+ if (changed.has('state')) {
56
+ s.update(ctx.state)
57
+ }
58
+
59
+ // Source-section changes → soft reload at the current location so the
60
+ // new component bodies are picked up without a full page refresh.
61
+ // `assets` triggers the same rerender so updated variant URLs (e.g.
62
+ // a `?v=<mtime>` bump on a replaced image) propagate into the rendered
63
+ // <img>/<source> tags — without this, byte-edits to assets would
64
+ // refresh the manifest in ctx but never re-render the DOM.
65
+ if (
66
+ ['pages', 'components', 'snippets', 'functions', 'assets'].some((k) =>
67
+ changed.has(k)
68
+ )
69
+ ) {
70
+ const { pathname, search, hash } = ctx.window.location
71
+ el.call(
72
+ 'router',
73
+ pathname + search + hash,
74
+ el.__ref.root,
75
+ {},
76
+ { scrollToTop: false }
77
+ )
78
+ }
79
+
80
+ if (changed.has('designSystem')) {
81
+ init(ctx.designSystem)
82
+ }
83
+ }
package/index.js CHANGED
@@ -1,83 +1,67 @@
1
- import { router } from '@domql/router'
2
- import { init } from 'smbls/src/init.js'
1
+ import { router } from '@symbo.ls/router'
3
2
  import { io } from 'socket.io-client'
4
- import { window, overwriteShallow, overwriteDeep } from '@domql/utils'
5
- import { connectedToSymbols, Notifications } from './SyncNotifications'
6
- import { Inspect } from './Inspect'
7
- export { Inspect, Notifications }
3
+ import { window } from '@symbo.ls/utils'
4
+ import {
5
+ FORBIDDEN_SEGMENTS,
6
+ isSafePath,
7
+ deletePath,
8
+ setPath,
9
+ applyOpsToCtx
10
+ } from './applier.js'
11
+ import { onSnapshot, onOps } from './handlers.js'
12
+ import { connectedToSymbols, Notifications } from './SyncNotifications.js'
13
+ export { Notifications }
14
+ export {
15
+ FORBIDDEN_SEGMENTS,
16
+ isSafePath,
17
+ deletePath,
18
+ setPath,
19
+ applyOpsToCtx,
20
+ onSnapshot,
21
+ onOps
22
+ }
8
23
 
9
24
  const isLocal = process.env.NODE_ENV === 'local'
10
25
 
11
- // ---------------------------------------------
12
- // Utility helpers to apply ops
26
+ const defaultBaseUrl = () =>
27
+ isLocal ? 'http://localhost:8080' : 'https://api.symbols.app'
13
28
 
14
- const deletePath = (obj, path) => {
15
- if (!obj || !Array.isArray(path)) {
16
- return
29
+ /*
30
+ * Detect which transport this page should speak. Two modes:
31
+ *
32
+ * collab — page was rendered by mermaid (hosted dev/staging/prod
33
+ * deploys). `ctx.editor.socketUrl` is injected at render time and
34
+ * points at the per-channel realtime API. Auth is a service token
35
+ * fetched from `${socketUrl}/service-token`; room is keyed by
36
+ * `ctx.key` (project key, or `editor.projectId` when present).
37
+ *
38
+ * runner — page was rendered by `@symbo.ls/runner` locally. The
39
+ * runner's HTTP server exposes a same-origin socket.io endpoint at
40
+ * the default `/socket.io` path with no auth. Detection:
41
+ * `ctx.editor.runtime === 'runner'` — the runner's boot script
42
+ * stamps that onto `context.editor` before calling `Smbls.create`.
43
+ * No window globals.
44
+ *
45
+ * Both modes attach the same applier handlers (onSnapshot/onOps) so the
46
+ * server-side wire format is interchangeable. Production builds (no
47
+ * socketUrl, no runner runtime) skip sync entirely.
48
+ */
49
+ const detectTransport = (ctx) => {
50
+ const socketUrl =
51
+ (ctx && ctx.editor && ctx.editor.socketUrl) ||
52
+ (ctx && ctx.settings && ctx.settings.socketUrl)
53
+ if (typeof socketUrl === 'string' && socketUrl) {
54
+ return { mode: 'collab', baseUrl: socketUrl }
17
55
  }
18
- path.reduce((acc, v, i, arr) => {
19
- if (acc && v in acc) {
20
- if (i !== arr.length - 1) {
21
- return acc[v]
22
- }
23
- delete acc[v]
24
- }
25
- return void 0
26
- }, obj)
27
- }
28
-
29
- const setPath = (obj, path, value, createNestedObjects = false) => {
30
- if (!obj || !Array.isArray(path)) {
31
- return
56
+ if (ctx && ctx.editor && ctx.editor.runtime === 'runner') {
57
+ return { mode: 'runner', baseUrl: null }
32
58
  }
33
- path.reduce((acc, v, i, arr) => {
34
- if (!acc) {
35
- return void 0
36
- }
37
- if (i !== arr.length - 1) {
38
- if (!acc[v] && createNestedObjects) {
39
- acc[v] = {}
40
- }
41
- return acc[v]
42
- }
43
- acc[v] = value
44
- return void 0
45
- }, obj)
59
+ return { mode: null, baseUrl: null }
46
60
  }
47
61
 
48
- const applyOpsToCtx = (ctx, changes) => {
49
- const topLevelChanged = new Set()
50
- if (!Array.isArray(changes)) {
51
- return topLevelChanged
52
- }
53
- for (const [action, path, change] of changes) {
54
- if (!Array.isArray(path) || !path.length) {
55
- continue
56
- }
57
- topLevelChanged.add(path[0])
58
- switch (action) {
59
- case 'delete':
60
- deletePath(ctx, path)
61
- break
62
- case 'update':
63
- case 'set':
64
- setPath(ctx, path, change, true)
65
- break
66
- default:
67
- // Unsupported action – ignore
68
- break
69
- }
70
- }
71
- return topLevelChanged
72
- }
73
-
74
- // ---------------------------------------------
75
-
76
- const fetchServiceToken = async () => {
62
+ const fetchServiceToken = async (baseUrl) => {
77
63
  try {
78
- const urlBase = isLocal
79
- ? 'http://localhost:8080'
80
- : 'https://api.symbols.app'
64
+ const urlBase = baseUrl || defaultBaseUrl()
81
65
  const res = await window.fetch(`${urlBase}/service-token`, {
82
66
  method: 'GET'
83
67
  })
@@ -103,117 +87,42 @@ const fetchServiceToken = async () => {
103
87
  }
104
88
  }
105
89
 
106
- const onSnapshot =
107
- (el, s, ctx) =>
108
- (payload = {}) => {
109
- let { data } = payload
110
- const { schema } = payload
111
- if (!data) {
112
- return
113
- }
114
-
115
- data = el.call(
116
- 'deepDestringifyFunctions',
117
- data,
118
- Array.isArray(data) ? [] : {}
119
- )
120
-
121
- // Overwrite high-level objects shallowly so references are preserved
122
- Object.entries(data).forEach(([key, val]) => {
123
- if (ctx[key] && typeof ctx[key] === 'object') {
124
- if (key === 'designSystem') {
125
- init(val)
126
- } else {
127
- overwriteShallow(ctx[key], val)
128
- }
129
- } else {
130
- ctx[key] = val
131
- }
132
- })
133
-
134
- // Optionally make schema available on ctx
135
- if (schema) {
136
- ctx.schema = schema
137
- }
138
- }
139
-
140
- const onOps =
141
- (el, s, ctx) =>
142
- (payload = {}) => {
143
- let { changes } = payload
144
- if (!changes || !Array.isArray(changes) || !changes.length) {
145
- return
146
- }
147
-
148
- changes = el.call(
149
- 'deepDestringifyFunctions',
150
- changes,
151
- Array.isArray(changes) ? [] : {}
152
- )
153
-
154
-
155
- const changed = applyOpsToCtx(ctx, changes)
156
-
157
- // React to specific top-level changes
158
- if (changed.has('state')) {
159
- const route = ctx.state?.route
160
- if (route) {
161
- el.call(
162
- 'router',
163
- route.replace('/state', '') || '/',
164
- el.__ref.root,
165
- {},
166
- { scrollToTop: false }
167
- )
168
- } else {
169
- s.update(ctx.state)
170
- }
171
- }
172
-
173
- if (
174
- ['pages', 'components', 'snippets', 'functions'].some((k) =>
175
- changed.has(k)
176
- )
177
- ) {
178
- const { pathname, search, hash } = ctx.window.location
179
- el.call(
180
- 'router',
181
- pathname + search + hash,
182
- el.__ref.root,
183
- {},
184
- { scrollToTop: false }
185
- )
186
- }
187
-
188
- if (changed.has('designSystem')) {
189
- init(ctx.designSystem)
190
- }
191
- }
90
+ /**
91
+ * Wire the shared applier handlers onto a connected socket. Used by both
92
+ * collab and runner transports so the snapshot/ops protocol is uniform.
93
+ */
94
+ const attachApplier = (socket, el, s, ctx) => {
95
+ socket.on('snapshot', onSnapshot(el, s, ctx))
96
+ socket.on('ops', onOps(el, s, ctx))
97
+ socket.on('clients', (data) => {
98
+ if (ctx.editor?.verbose) connectedToSymbols(data, el, s)
99
+ })
100
+ socket.on('disconnect', (reason) => {
101
+ if (ctx.editor?.verbose) console.info('[sync] disconnected', reason)
102
+ })
103
+ }
192
104
 
193
- export const connectToSocket = async (el, s, ctx) => {
194
- const token = await fetchServiceToken()
105
+ const connectCollab = async (el, s, ctx, baseUrl) => {
106
+ const token = await fetchServiceToken(baseUrl)
195
107
  if (!token) {
196
- console.warn('[sync] No service token live collaboration disabled')
108
+ console.warn('[sync] no service token collab disabled')
197
109
  return null
198
110
  }
199
-
200
111
  const projectKey = ctx.key
201
112
  if (!projectKey) {
202
- console.warn(
203
- '[sync] ctx.key missing – cannot establish collaborative connection'
204
- )
113
+ console.warn('[sync] ctx.key missing — collab cannot authenticate')
205
114
  return null
206
115
  }
116
+ // The server prefers `projectId` (mongo _id) over `projectKey` lookup;
117
+ // mermaid injects it via `editor.projectId` to skip a 404-prone roundtrip.
118
+ const projectId = ctx.editor?.projectId || null
207
119
 
208
- const socketBaseUrl = isLocal
209
- ? 'http://localhost:8080'
210
- : 'https://api.symbols.app'
211
-
212
- const socket = io(socketBaseUrl, {
120
+ const socket = io(baseUrl, {
213
121
  path: '/collab-socket',
214
122
  transports: ['websocket'],
215
123
  auth: {
216
124
  token,
125
+ projectId,
217
126
  projectKey,
218
127
  branch: 'main',
219
128
  live: true,
@@ -224,33 +133,126 @@ export const connectToSocket = async (el, s, ctx) => {
224
133
  })
225
134
 
226
135
  socket.on('connect', () => {
227
- if (ctx.editor?.verbose) {
228
- console.info('[sync] Connected to collab socket')
229
- }
136
+ if (ctx.editor?.verbose) console.info('[sync] connected (collab)')
230
137
  })
138
+ attachApplier(socket, el, s, ctx)
139
+ return socket
140
+ }
231
141
 
232
- socket.on('snapshot', onSnapshot(el, s, ctx))
233
- socket.on('ops', onOps(el, s, ctx))
234
-
235
- socket.on('clients', (data) => {
236
- if (ctx.editor?.verbose) {
237
- connectedToSymbols(data, el, s)
238
- }
142
+ const connectRunner = (el, s, ctx) => {
143
+ // Same-origin /socket.io (default path), no auth — the runner is a
144
+ // local-only dev surface. Auto-reconnect so a server restart doesn't
145
+ // require a browser refresh.
146
+ const socket = io({
147
+ transports: ['websocket', 'polling'],
148
+ reconnectionAttempts: Infinity,
149
+ reconnectionDelayMax: 4000
239
150
  })
240
-
241
- socket.on('disconnect', (reason) => {
242
- if (ctx.editor?.verbose) {
243
- console.info('[sync] Disconnected from collab socket', reason)
151
+ socket.on('connect', () => {
152
+ if (ctx.editor?.verbose !== false) console.info('[sync] connected (runner)')
153
+ })
154
+ attachApplier(socket, el, s, ctx)
155
+ // Runner emits `reload` when something the wire format can't express
156
+ // changes (importmap shift, add/remove of dependencies). The browser
157
+ // must do a full reload to pick up the new HTML scaffold.
158
+ socket.on('reload', () => {
159
+ if (typeof window !== 'undefined' && window.location) {
160
+ window.location.reload()
244
161
  }
245
162
  })
246
-
247
163
  return socket
248
164
  }
249
165
 
166
+ export const connectToSocket = async (el, s, ctx) => {
167
+ const { mode, baseUrl } = detectTransport(ctx)
168
+ if (!mode) return null
169
+ if (mode === 'collab') return connectCollab(el, s, ctx, baseUrl)
170
+ if (mode === 'runner') return connectRunner(el, s, ctx)
171
+ return null
172
+ }
173
+
174
+ // `onInitSync` was a custom lifecycle name that v3.14 never actually fires —
175
+ // `initSync` is registered in LIFECYCLE_EVENTS but no code triggers it, so the
176
+ // handler stayed dead and the realtime socket never opened on deployed pages.
177
+ // `onInit` is a fired root-element lifecycle: when SyncComponent is pushed
178
+ // into the app's `extends` (only on the root by initializeSync), this runs
179
+ // once at app boot and connects to the collab/mermaid backend.
250
180
  export const SyncComponent = {
251
- onInitSync: connectToSocket
181
+ onInit: connectToSocket
252
182
  }
253
183
 
184
+ // `Inspect` moved to `@symbo.ls/inspect`. Compose it manually if you want
185
+ // the legacy DefaultSyncApp shape:
186
+ // import { Inspect } from '@symbo.ls/inspect'
187
+ // const DefaultApp = { extends: [SyncComponent, Inspect, Notifications] }
254
188
  export const DefaultSyncApp = {
255
- extends: [SyncComponent, Inspect, Notifications]
189
+ extends: [SyncComponent, Notifications]
190
+ }
191
+
192
+ const isUndef = (v) => typeof v === 'undefined'
193
+ const hasCollab = (ctx) => !!(ctx?.editor?.socketUrl || ctx?.settings?.socketUrl)
194
+ const hasRunner = (ctx) => ctx?.editor?.runtime === 'runner'
195
+
196
+ const attachExtend = (app, component) => {
197
+ if (Array.isArray(app.extends)) app.extends.push(component)
198
+ else if (app.extends) app.extends = [app.extends, component]
199
+ else app.extends = [component]
200
+ }
201
+
202
+ /**
203
+ * @symbo.ls/sync as a regular smbls plugin. Auto-registered by
204
+ * `prepareContext` in `packages/smbls/src/createDomql.js` (default-on);
205
+ * users opt out by setting `context.sync = false` or by removing it from
206
+ * `context.plugins` before calling `Smbls.create(...)`.
207
+ *
208
+ * The `beforeCreate(app, ctx)` lifecycle runs after `initializeExtend`
209
+ * has set the root `app.extends` array but before DOMQL element creation,
210
+ * so attaching SyncComponent here causes its `onInit` to fire as a normal
211
+ * root-element lifecycle event against the resolved app — no window
212
+ * globals, no Promise race.
213
+ */
214
+ export const syncPlugin = {
215
+ name: 'sync',
216
+ beforeCreate (app, ctx) {
217
+ const editor = ctx && ctx.editor
218
+
219
+ // Precedence:
220
+ // explicit editor.liveSync → wins
221
+ // else: editor.socketUrl (collab) OR editor.runtime === 'runner'
222
+ // else: off (production / static dist)
223
+ const liveSync = editor && !isUndef(editor.liveSync)
224
+ ? editor.liveSync
225
+ : (hasCollab(ctx) || hasRunner(ctx))
226
+ if (!liveSync) return
227
+
228
+ // Collab transport requires ctx.key for socket auth. Runner doesn't.
229
+ if (hasCollab(ctx) && !hasRunner(ctx) && !ctx?.key) return
230
+
231
+ attachExtend(app, SyncComponent)
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Notifications overlay (toast stack at bottom-left). Visualises sync
237
+ * connect/disconnect events surfaced by `connectedToSymbols`. Lives in
238
+ * `@symbo.ls/sync` rather than its own package because the helper that
239
+ * fires notifications is owned by sync's collab transport.
240
+ *
241
+ * Auto-registered by `prepareContext` only when the user opts in
242
+ * (default OFF). Two opt-in shapes for backwards compatibility:
243
+ * 1. `context.notifications = true` (preferred — same shape as
244
+ * `context.sync` / `context.inspect`)
245
+ * 2. `context.editor.verbose = true` (legacy — historic behaviour
246
+ * where auto-on-in-dev also turned this on)
247
+ */
248
+ export const notificationsPlugin = {
249
+ name: 'notifications',
250
+ beforeCreate (app, ctx) {
251
+ if (!ctx) return
252
+ const explicit = !isUndef(ctx.notifications) ? ctx.notifications : null
253
+ const editorVerbose = ctx.editor && ctx.editor.verbose === true
254
+ const enabled = explicit === true || (explicit !== false && editorVerbose)
255
+ if (!enabled) return
256
+ attachExtend(app, Notifications)
257
+ }
256
258
  }