@wix/himalaya-cli 0.1451.0 → 0.1453.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.
@@ -1,25 +1,15 @@
1
- // ../../core/sdk/webview/src/index.ts
2
- function runtime() {
3
- const himalaya = globalThis.himalaya;
4
- if (!himalaya?.webview) {
5
- throw new Error(
6
- '@himalaya/webview: called outside a Himalaya runtime, or the host did not install the webview bridge. This binding must run inside a worker-backed webview screen (Tier 5 + webview renderer) with the "webview" capability declared.'
7
- );
8
- }
9
- return himalaya.webview;
10
- }
11
- function postToPage(msg) {
12
- runtime().postToPage(msg);
13
- }
14
- function onPageMessage(cb) {
15
- runtime().onPageMessage(cb);
16
- }
1
+ // ../../core/sdk/webview/src/dispatcher.ts
17
2
  var PAGE_HANDSHAKE_CAPABILITY = "himalaya";
18
3
  var PAGE_HANDSHAKE_METHOD = "capabilities";
4
+ var RESERVED_NAMESPACES = /* @__PURE__ */ new Set(["call", "on", "probe", "version", "then", "constructor", "general"]);
5
+ var UNREACHABLE_SEGMENTS = /* @__PURE__ */ new Set(["then", "toString", "valueOf"]);
6
+ var NAMESPACE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
7
+ var METHOD_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}(?:\.[A-Za-z][A-Za-z0-9_]{0,63}){0,3}$/;
8
+ var ID_PATTERN = /^[A-Za-z0-9-]{1,96}$/;
19
9
  function pageRequest(value) {
20
10
  if (!value || typeof value !== "object") return null;
21
11
  const request = value;
22
- if (request.v !== 1 || typeof request.id !== "string" || !/^[A-Za-z0-9-]{1,96}$/.test(request.id) || typeof request.capability !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(request.capability) || typeof request.method !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}(?:\.[A-Za-z][A-Za-z0-9_]{0,63}){0,3}$/.test(request.method) || !Array.isArray(request.args) || request.args.length > 12) return null;
12
+ if (request.v !== 1 || typeof request.id !== "string" || !ID_PATTERN.test(request.id) || typeof request.capability !== "string" || !NAMESPACE_PATTERN.test(request.capability) || typeof request.method !== "string" || !METHOD_PATTERN.test(request.method) || !Array.isArray(request.args) || request.args.length > 12) return null;
23
13
  return request;
24
14
  }
25
15
  function pageError(value) {
@@ -30,52 +20,518 @@ function pageError(value) {
30
20
  ...typeof code === "string" && code ? { code } : {}
31
21
  };
32
22
  }
33
- function exposeCapabilitiesToPage(capabilities) {
34
- if (Object.prototype.hasOwnProperty.call(capabilities, PAGE_HANDSHAKE_CAPABILITY)) {
35
- throw new Error(`"${PAGE_HANDSHAKE_CAPABILITY}" is reserved for the page handshake and cannot be exposed`);
36
- }
37
- const allowed = new Map(
38
- Object.entries(capabilities).map(([id, entry]) => [id, {
39
- module: entry.module,
40
- methods: new Set(entry.methods)
41
- }])
42
- );
43
- const manifest = Object.fromEntries(
44
- [...allowed].map(([id, entry]) => [id, [...entry.methods].sort()])
45
- );
46
- onPageMessage((message) => {
47
- const request = pageRequest(message);
48
- if (!request) return;
49
- if (request.capability === PAGE_HANDSHAKE_CAPABILITY) {
50
- if (request.method === PAGE_HANDSHAKE_METHOD) {
51
- postToPage({ v: 1, id: request.id, ok: true, result: { v: 1, capabilities: manifest } });
23
+ var key = (instanceId, id) => `${instanceId}\0${id}`;
24
+ var instanceOf = (value) => typeof value === "string" ? value : "";
25
+ function createPageDispatcher(runtime2) {
26
+ let installed = false;
27
+ let lifecycleInstalled = false;
28
+ let raw = null;
29
+ const exposed = /* @__PURE__ */ new Map();
30
+ const namespaces = /* @__PURE__ */ new Map();
31
+ const inFlight = /* @__PURE__ */ new Map();
32
+ const lingering = /* @__PURE__ */ new Map();
33
+ let calling = null;
34
+ const crashed = /* @__PURE__ */ new Set();
35
+ const streams = /* @__PURE__ */ new Map();
36
+ const handles = /* @__PURE__ */ new WeakMap();
37
+ const live = /* @__PURE__ */ new Set();
38
+ const inactive = /* @__PURE__ */ new Set();
39
+ const gone = /* @__PURE__ */ new Set();
40
+ const lifecycleSubscribers = /* @__PURE__ */ new Set();
41
+ const consoleSubscribers = /* @__PURE__ */ new Set();
42
+ const instanceAware = () => typeof runtime2.onPageLifecycle === "function";
43
+ const register = runtime2.onPageMessage;
44
+ try {
45
+ runtime2.onPageMessage = (cb) => {
46
+ raw = typeof cb === "function" ? (msg, instanceId) => cb(msg, instanceId) : null;
47
+ if (!installed) register.call(runtime2, cb);
48
+ };
49
+ } catch {
50
+ }
51
+ function assertFree(name) {
52
+ if (exposed.has(name)) throw new Error(`page capability "${name}" is already exposed`);
53
+ if (namespaces.has(name)) throw new Error(`page namespace "${name}" is already registered`);
54
+ }
55
+ function install() {
56
+ if (!installed) {
57
+ installed = true;
58
+ register.call(runtime2, (msg, instanceId) => onPage(msg, instanceOf(instanceId)));
59
+ }
60
+ if (!lifecycleInstalled && instanceAware()) {
61
+ lifecycleInstalled = true;
62
+ runtime2.onPageLifecycle((envelope, instanceId) => onLifecycle(envelope, instanceId));
63
+ }
64
+ }
65
+ function setRawHandler(cb) {
66
+ raw = cb;
67
+ install();
68
+ }
69
+ function assertReachable(name, kind) {
70
+ if (typeof name !== "string" || !NAMESPACE_PATTERN.test(name)) {
71
+ throw new Error(`page ${kind} ${JSON.stringify(name)} must match ${NAMESPACE_PATTERN}`);
72
+ }
73
+ if (RESERVED_NAMESPACES.has(name)) {
74
+ throw new Error(`page ${kind} "${name}" is reserved: the page's himalaya.${name} is not a namespace`);
75
+ }
76
+ }
77
+ function expose(capabilities) {
78
+ for (const id of Object.keys(capabilities)) {
79
+ if (id === PAGE_HANDSHAKE_CAPABILITY) {
80
+ throw new Error(`"${PAGE_HANDSHAKE_CAPABILITY}" is reserved for the page handshake and cannot be exposed`);
52
81
  }
82
+ assertReachable(id, "capability");
83
+ assertFree(id);
84
+ }
85
+ for (const [id, entry] of Object.entries(capabilities)) {
86
+ exposed.set(id, { module: entry.module, methods: new Set(entry.methods) });
87
+ }
88
+ install();
89
+ }
90
+ function addNamespace(namespace, handlers) {
91
+ if (namespace === PAGE_HANDSHAKE_CAPABILITY) {
92
+ throw new Error(`"${PAGE_HANDSHAKE_CAPABILITY}" is reserved for the page handshake and cannot be a namespace`);
93
+ }
94
+ assertReachable(namespace, "namespace");
95
+ assertFree(namespace);
96
+ const methods = /* @__PURE__ */ new Map();
97
+ for (const [method, handler] of Object.entries(handlers ?? {})) {
98
+ if (!METHOD_PATTERN.test(method)) {
99
+ throw new Error(`page method ${JSON.stringify(`${namespace}.${method}`)} must match ${METHOD_PATTERN}`);
100
+ }
101
+ const segments = method.split(".");
102
+ if (segments[0] === "on" || segments.some((segment) => UNREACHABLE_SEGMENTS.has(segment))) {
103
+ throw new Error(`page method ${JSON.stringify(`${namespace}.${method}`)} is reserved: the page proxy cannot call it`);
104
+ }
105
+ if (typeof handler !== "function") throw new Error(`page handler ${namespace}.${method} is not a function`);
106
+ methods.set(method, handler);
107
+ }
108
+ namespaces.set(namespace, methods);
109
+ install();
110
+ }
111
+ function subscribeLifecycle(cb) {
112
+ lifecycleSubscribers.add(cb);
113
+ install();
114
+ return () => {
115
+ lifecycleSubscribers.delete(cb);
116
+ };
117
+ }
118
+ function subscribeConsole(cb) {
119
+ consoleSubscribers.add(cb);
120
+ install();
121
+ return () => {
122
+ consoleSubscribers.delete(cb);
123
+ };
124
+ }
125
+ function post(instanceId, msg) {
126
+ if (instanceAware()) runtime2.postToPage(msg, instanceId);
127
+ else if (instanceId === "") runtime2.postToPage(msg);
128
+ }
129
+ function emit(target, event, data) {
130
+ install();
131
+ const dot = typeof event === "string" ? event.indexOf(".") : -1;
132
+ const ns = dot > 0 ? event.slice(0, dot) : "";
133
+ const ev = dot > 0 ? event.slice(dot + 1) : "";
134
+ if (!NAMESPACE_PATTERN.test(ns) || !ev) {
135
+ throw new Error(`emitToPage event ${JSON.stringify(event)} must be "<namespace>.<event>"`);
136
+ }
137
+ const envelope = { v: 1, ev, ns, data };
138
+ if (target === "*") {
139
+ const targets = instanceAware() ? live : /* @__PURE__ */ new Set([...live, ""]);
140
+ for (const instanceId of [...targets]) post(instanceId, envelope);
141
+ } else {
142
+ post(target, envelope);
143
+ }
144
+ }
145
+ function openStream(instanceId, requestId) {
146
+ const k = key(instanceId, requestId);
147
+ let pending;
148
+ let ambiguous = false;
149
+ if (calling?.k === k) pending = calling.pending;
150
+ else {
151
+ pending = inFlight.get(k);
152
+ ambiguous = (lingering.get(k)?.size ?? 0) > 0;
153
+ }
154
+ const existing = streams.get(k);
155
+ if (existing && !ambiguous && pending && existing.owner === pending) return existing.handle;
156
+ if (pending?.abandoned || ambiguous || pending && existing) {
157
+ const dead = {
158
+ owner: null,
159
+ instanceId,
160
+ sid: requestId,
161
+ buffer: null,
162
+ closed: true,
163
+ aborted: true,
164
+ cancelled: [],
165
+ handle: void 0
166
+ };
167
+ dead.handle = {
168
+ next: () => {
169
+ },
170
+ end: () => {
171
+ },
172
+ error: () => {
173
+ },
174
+ onCancel: (cb) => {
175
+ if (typeof cb === "function") runCancel(cb);
176
+ },
177
+ get closed() {
178
+ return true;
179
+ }
180
+ };
181
+ handles.set(dead.handle, dead);
182
+ return dead.handle;
183
+ }
184
+ if (!pending) {
185
+ throw new Error(`openPageStream: no request ${JSON.stringify(requestId)} is awaiting an answer on instance ${JSON.stringify(instanceId)}`);
186
+ }
187
+ const state = {
188
+ owner: pending,
189
+ instanceId,
190
+ sid: requestId,
191
+ buffer: [],
192
+ closed: false,
193
+ aborted: false,
194
+ cancelled: [],
195
+ handle: void 0
196
+ };
197
+ const send = (envelope, last) => {
198
+ if (state.closed) return;
199
+ if (last) state.closed = true;
200
+ if (state.buffer) state.buffer.push(envelope);
201
+ else post(instanceId, envelope);
202
+ if (last && !state.buffer && streams.get(k) === state) streams.delete(k);
203
+ };
204
+ state.handle = {
205
+ // JSON drops an `undefined` member, and a page reads `{ v, sid }` alone as no chunk at all.
206
+ next: (data) => send({ v: 1, sid: state.sid, chunk: data === void 0 ? null : data }, false),
207
+ end: () => send({ v: 1, sid: state.sid, end: true }, true),
208
+ error: (e) => send({ v: 1, sid: state.sid, error: pageError(e) }, true),
209
+ onCancel: (cb) => {
210
+ if (typeof cb !== "function") return;
211
+ if (state.aborted) runCancel(cb);
212
+ else state.cancelled.push(cb);
213
+ },
214
+ get closed() {
215
+ return state.closed;
216
+ }
217
+ };
218
+ streams.set(k, state);
219
+ handles.set(state.handle, state);
220
+ return state.handle;
221
+ }
222
+ function onPage(msg, instanceId) {
223
+ const sys = msg && typeof msg === "object" && msg.v === 1 ? msg.sys : void 0;
224
+ if (sys === "cancel") return cancel(instanceId, msg.sid);
225
+ if (sys === "console") return forwardConsole(instanceId, msg);
226
+ const dead = gone.has(instanceId);
227
+ if (!dead) live.add(instanceId);
228
+ const request = exposed.size > 0 || namespaces.size > 0 ? pageRequest(msg) : null;
229
+ if (!request) {
230
+ raw?.(msg, instanceId);
231
+ return;
232
+ }
233
+ if (!dead) route(request, instanceId, msg);
234
+ }
235
+ function route(request, instanceId, message) {
236
+ const respond = (response) => post(instanceId, { v: 1, id: request.id, ...response });
237
+ if (request.capability === PAGE_HANDSHAKE_CAPABILITY) {
238
+ if (request.method === PAGE_HANDSHAKE_METHOD) respond({ ok: true, result: { v: 1, capabilities: manifest() } });
239
+ return;
240
+ }
241
+ const capability = exposed.get(request.capability);
242
+ if (capability) {
243
+ const { fn, receiver } = resolvePath(capability.module, request.method);
244
+ if (!capability.methods.has(request.method) || !fn) return;
245
+ const pending2 = begin(key(instanceId, request.id));
246
+ void Promise.resolve().then(() => fn.apply(receiver, request.args)).then(
247
+ (result) => {
248
+ if (settle(key(instanceId, request.id), pending2)) respond({ ok: true, result });
249
+ },
250
+ (error) => {
251
+ if (settle(key(instanceId, request.id), pending2)) respond({ ok: false, error: pageError(error) });
252
+ }
253
+ );
254
+ return;
255
+ }
256
+ const methods = namespaces.get(request.capability);
257
+ if (!methods) {
258
+ raw?.(message, instanceId);
53
259
  return;
54
260
  }
55
- const capability = allowed.get(request.capability);
56
- let candidate = capability?.module;
57
- let receiver = void 0;
58
- for (const segment of request.method.split(".")) {
59
- if (!candidate || typeof candidate !== "object" || !Object.prototype.hasOwnProperty.call(candidate, segment)) {
60
- candidate = void 0;
261
+ const handler = methods.get(request.method);
262
+ if (!handler) {
263
+ respond({
264
+ ok: false,
265
+ error: { message: `Unknown page method: ${request.capability}.${request.method}`, code: "unknown_method" }
266
+ });
267
+ return;
268
+ }
269
+ const k = key(instanceId, request.id);
270
+ const pending = begin(k);
271
+ const ctx = {
272
+ instanceId,
273
+ requestId: request.id,
274
+ get active() {
275
+ return !inactive.has(instanceId) && !gone.has(instanceId);
276
+ }
277
+ };
278
+ void Promise.resolve().then(() => {
279
+ calling = { k, pending };
280
+ try {
281
+ return handler(request.args, ctx);
282
+ } finally {
283
+ calling = null;
284
+ }
285
+ }).then(
286
+ (result) => {
287
+ const reader = settle(k, pending);
288
+ const stream = ownStream(k, pending);
289
+ if (!reader) {
290
+ if (stream) discard(k, stream);
291
+ return;
292
+ }
293
+ if (stream && result === stream.handle) {
294
+ respond({ ok: true, stream: true });
295
+ const buffered = stream.buffer ?? [];
296
+ stream.buffer = null;
297
+ for (const envelope of buffered) post(instanceId, envelope);
298
+ if (stream.closed && streams.get(k) === stream) streams.delete(k);
299
+ return;
300
+ }
301
+ if (stream) discard(k, stream);
302
+ const returned = result && typeof result === "object" ? handles.get(result) : void 0;
303
+ if (returned) {
304
+ respond({
305
+ ok: false,
306
+ error: { message: "The page stream was closed before it was returned", code: "stream_closed" }
307
+ });
308
+ return;
309
+ }
310
+ respond({ ok: true, result });
311
+ },
312
+ (error) => {
313
+ const reader = settle(k, pending);
314
+ const stream = ownStream(k, pending);
315
+ if (stream) discard(k, stream);
316
+ if (reader) respond({ ok: false, error: pageError(error) });
317
+ }
318
+ );
319
+ }
320
+ function settle(k, pending) {
321
+ if (inFlight.get(k) === pending) inFlight.delete(k);
322
+ const left = lingering.get(k);
323
+ if (left?.delete(pending) && left.size === 0) lingering.delete(k);
324
+ return !pending.abandoned;
325
+ }
326
+ function begin(k) {
327
+ const previous = inFlight.get(k);
328
+ if (previous && !previous.abandoned) {
329
+ abandon(k, previous);
330
+ const stream = ownStream(k, previous);
331
+ if (stream) discard(k, stream);
332
+ }
333
+ const pending = { abandoned: false };
334
+ inFlight.set(k, pending);
335
+ return pending;
336
+ }
337
+ function abandon(k, pending) {
338
+ pending.abandoned = true;
339
+ let left = lingering.get(k);
340
+ if (!left) lingering.set(k, left = /* @__PURE__ */ new Set());
341
+ left.add(pending);
342
+ }
343
+ function ownStream(k, pending) {
344
+ const stream = streams.get(k);
345
+ return stream?.owner === pending ? stream : void 0;
346
+ }
347
+ function manifest() {
348
+ const out = {};
349
+ for (const [id, entry] of exposed) out[id] = [...entry.methods].sort();
350
+ for (const [ns, methods] of namespaces) out[ns] = [...methods.keys()].sort();
351
+ return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
352
+ }
353
+ function runCancel(cb) {
354
+ try {
355
+ cb();
356
+ } catch {
357
+ }
358
+ }
359
+ function discard(k, stream) {
360
+ const wasOpen = !stream.closed;
361
+ stream.closed = true;
362
+ stream.buffer = null;
363
+ if (streams.get(k) === stream) streams.delete(k);
364
+ if (!wasOpen || stream.aborted) return;
365
+ stream.aborted = true;
366
+ for (const cb of stream.cancelled.splice(0)) runCancel(cb);
367
+ }
368
+ function cancel(instanceId, sid) {
369
+ if (typeof sid !== "string") return;
370
+ const k = key(instanceId, sid);
371
+ const stream = streams.get(k);
372
+ if (stream) discard(k, stream);
373
+ }
374
+ function forwardConsole(instanceId, msg) {
375
+ const event = {
376
+ instanceId,
377
+ level: typeof msg.level === "string" ? msg.level : "log",
378
+ args: Array.isArray(msg.args) ? msg.args : []
379
+ };
380
+ for (const cb of [...consoleSubscribers]) {
381
+ try {
382
+ cb(event);
383
+ } catch {
384
+ }
385
+ }
386
+ }
387
+ function onLifecycle(rawEnvelope, stamped) {
388
+ let envelope = rawEnvelope;
389
+ if (typeof envelope === "string") {
390
+ try {
391
+ envelope = JSON.parse(envelope);
392
+ } catch {
393
+ return;
394
+ }
395
+ }
396
+ const e = envelope;
397
+ if (!e || typeof e !== "object" || e.v !== 1 || e.sys !== "lifecycle" || typeof e.state !== "string") return;
398
+ const instanceId = typeof stamped === "string" ? stamped : instanceOf(e.iid);
399
+ switch (e.state) {
400
+ // A document start ends any crash pair: a host that restarts a crashed instance this way
401
+ // sends no `reloaded` for it, so the next `reloaded` is a boundary of its own.
402
+ case "mounted":
403
+ live.add(instanceId);
404
+ gone.delete(instanceId);
405
+ crashed.delete(instanceId);
406
+ break;
407
+ case "loaded":
408
+ crashed.delete(instanceId);
409
+ break;
410
+ case "active":
411
+ inactive.delete(instanceId);
412
+ break;
413
+ case "inactive":
414
+ inactive.add(instanceId);
415
+ break;
416
+ // A new document: the old one's streams and pending requests have no reader any more.
417
+ // `crashed` is followed by `reloaded` once the host remounted: one boundary, not two, or the
418
+ // second sweep would hit the NEW document's first requests.
419
+ case "crashed":
420
+ crashed.add(instanceId);
421
+ endDocument(instanceId);
61
422
  break;
423
+ case "reloaded":
424
+ if (!crashed.delete(instanceId)) endDocument(instanceId);
425
+ break;
426
+ case "unmounted":
427
+ unmount(instanceId);
428
+ break;
429
+ }
430
+ const event = { instanceId, state: e.state, ..."detail" in e ? { detail: e.detail } : {} };
431
+ for (const cb of [...lifecycleSubscribers]) {
432
+ try {
433
+ cb(event);
434
+ } catch {
62
435
  }
63
- receiver = candidate;
64
- candidate = candidate[segment];
65
436
  }
66
- if (!capability || !capability.methods.has(request.method) || typeof candidate !== "function") return;
67
- void Promise.resolve().then(() => candidate.apply(receiver, request.args)).then(
68
- (result) => postToPage({ v: 1, id: request.id, ok: true, result }),
69
- (error) => postToPage({ v: 1, id: request.id, ok: false, error: pageError(error) })
437
+ }
438
+ function endDocument(instanceId) {
439
+ for (const stream of [...streams.values()]) {
440
+ if (stream.instanceId === instanceId) cancel(instanceId, stream.sid);
441
+ }
442
+ const prefix = key(instanceId, "");
443
+ for (const [k, pending] of inFlight) {
444
+ if (!k.startsWith(prefix)) continue;
445
+ abandon(k, pending);
446
+ }
447
+ }
448
+ function unmount(instanceId) {
449
+ live.delete(instanceId);
450
+ inactive.delete(instanceId);
451
+ gone.add(instanceId);
452
+ crashed.delete(instanceId);
453
+ endDocument(instanceId);
454
+ }
455
+ return {
456
+ runtime: runtime2,
457
+ get instanceAware() {
458
+ return instanceAware();
459
+ },
460
+ setRawHandler,
461
+ expose,
462
+ addNamespace,
463
+ subscribeLifecycle,
464
+ subscribeConsole,
465
+ post,
466
+ emit,
467
+ openStream
468
+ };
469
+ }
470
+ function resolvePath(module, method) {
471
+ let candidate = module;
472
+ let receiver = void 0;
473
+ for (const segment of method.split(".")) {
474
+ if (!candidate || typeof candidate !== "object" || !Object.prototype.hasOwnProperty.call(candidate, segment)) {
475
+ return {};
476
+ }
477
+ receiver = candidate;
478
+ candidate = candidate[segment];
479
+ }
480
+ return typeof candidate === "function" ? { fn: candidate, receiver } : {};
481
+ }
482
+
483
+ // ../../core/sdk/webview/src/index.ts
484
+ function runtime() {
485
+ const himalaya = globalThis.himalaya;
486
+ if (!himalaya?.webview) {
487
+ throw new Error(
488
+ '@himalaya/webview: called outside a Himalaya runtime, or the host did not install the webview bridge. This binding must run inside a worker-backed webview screen (Tier 5 + webview renderer) with the "webview" capability declared.'
70
489
  );
71
- });
490
+ }
491
+ return himalaya.webview;
492
+ }
493
+ var current;
494
+ function dispatcher() {
495
+ const rt = runtime();
496
+ if (current?.runtime !== rt) current = createPageDispatcher(rt);
497
+ return current;
498
+ }
499
+ if (globalThis.himalaya?.webview) dispatcher();
500
+ function postToPage(msg) {
501
+ runtime().postToPage(msg);
502
+ }
503
+ function onPageMessage(cb) {
504
+ dispatcher().setRawHandler(cb);
505
+ }
506
+ function onPageRequest(namespace, handlers) {
507
+ dispatcher().addNamespace(namespace, handlers);
508
+ }
509
+ function emitToPage(instanceId, event, data) {
510
+ dispatcher().emit(instanceId, event, data);
511
+ }
512
+ function openPageStream(instanceId, requestId) {
513
+ return dispatcher().openStream(instanceId, requestId);
514
+ }
515
+ function onPageLifecycle(cb) {
516
+ return dispatcher().subscribeLifecycle(cb);
517
+ }
518
+ function onPageConsole(cb) {
519
+ return dispatcher().subscribeConsole(cb);
520
+ }
521
+ function exposeCapabilitiesToPage(capabilities) {
522
+ dispatcher().expose(capabilities);
72
523
  }
73
524
  var version = 1;
74
525
  export {
75
526
  PAGE_HANDSHAKE_CAPABILITY,
76
527
  PAGE_HANDSHAKE_METHOD,
528
+ emitToPage,
77
529
  exposeCapabilitiesToPage,
530
+ onPageConsole,
531
+ onPageLifecycle,
78
532
  onPageMessage,
533
+ onPageRequest,
534
+ openPageStream,
79
535
  postToPage,
80
536
  version
81
537
  };
@@ -337,7 +337,7 @@ to drive them:
337
337
 
338
338
  | State | Compose with | Notes |
339
339
  |---|---|---|
340
- | **Loading** | `Skeleton` (shape of the eventual content) or `StatefulSection` / `ActivityIndicator` | Prefer a content-shaped `Skeleton` over a bare spinner — it previews the layout. The descriptor renders against the state-skeleton, then repaints as worker state lands. |
340
+ | **Loading** | `Skeleton` (shape of the eventual content) or `StatefulSection` / `ActivityIndicator` | Required on every screen that loads data asynchronously, and re-checked on every redesign. Prefer a content-shaped `Skeleton` over a bare spinner — it previews the layout. The descriptor renders against the state-skeleton, then repaints as worker state lands. See [Loading states](#loading-states-part-of-the-design) below. |
341
341
  | **Empty** | `EmptyState` (`title`, optional `icon` / `subtitle`) **plus a directive action** (`action` + `actionLabel`) | An empty screen is an invitation to act, not a dead end. Give it the next step, for example `"actionLabel": "Add your first item"`. Use `EmptyState` for **every** empty state, so all empty states in the app have the same layout and style. See [Empty states](#empty-states-one-component-one-layout) below. |
342
342
  | **Error** | `Banner` (or inline) **with a retry action** | Say what failed and offer recovery; never a blank screen or a raw error string. |
343
343
  | **Loaded** | the real content | The one everyone remembers to build. |
@@ -347,6 +347,38 @@ Bind visibility to worker state with `hidden_path` (`$service:…`/`$item.` data
347
347
  components precisely so states are first-class — use them. (Reproducing an existing app? Enumerate
348
348
  every state the *original* shows; the same discipline applies to net-new screens.)
349
349
 
350
+ ### Loading states: part of the design
351
+
352
+ A screen that loads data asynchronously ships its loading state with it. The loading state is
353
+ part of the screen's design, not an implementation detail to fill in later: it is the first thing
354
+ the user sees, and on a slow network it is the whole screen.
355
+
356
+ - **New screens.** Every new screen that waits for data — a worker fetch, a Wix call, a
357
+ `$service:` value that arrives after the first paint — has a designed loading state.
358
+ - **Redesigns.** When you change the layout of an existing data screen, review its loading state
359
+ in the same change. Update it when its **structure** (sections added, removed or reordered),
360
+ **hierarchy** (a new hero, a different first row) or **content** (a list became a grid, a row
361
+ gained an image) no longer matches the redesigned screen. A loader that still shows the old
362
+ layout makes the screen jump when the data lands. Compare the two frames:
363
+ `himi render <screenId> --no-mocks` draws the initial frame, which is the loading state, and
364
+ `himi render <screenId> --state '{"<svc>":{…}}'` draws the loaded one. (A plain `himi render`
365
+ loads the app's recorded data by default when it has any, so it shows the loaded screen, not the
366
+ loader.)
367
+ - **Prefer a content-shaped `Skeleton`** when you know the loaded layout: the same sections, in
368
+ the same order, at about the same sizes (`width`, `height`, `cornerRadius`, and `count` for
369
+ repeated rows). It previews the layout, so nothing moves when the data arrives.
370
+ - **Use another existing loading component when it fits the behavior better.** `StatefulSection`
371
+ suits a section that loads on its own; it can skip the skeleton for a load that settles quickly
372
+ (`loadingGraceMs`) and holds a painted skeleton long enough not to flicker (`loadingFloorMs`).
373
+ `ActivityIndicator` suits a wait whose layout is not known in advance, or a short local wait
374
+ such as a pull-to-refresh or a button's own pending state.
375
+ - **Loading is its own state — not empty, not error.** Do not show the empty state while the data
376
+ is still loading, and do not leave the skeleton up after a load has failed. Each state has its own
377
+ node, driven by its own worker flag (see `himi docs recipe-four-state`).
378
+ - **Static screens have no loading state.** A screen whose content is already in the descriptor —
379
+ a settings list, an about page, a form with nothing to prefill — renders at once. Do not add a
380
+ skeleton to it.
381
+
350
382
  ### Empty states: one component, one layout
351
383
 
352
384
  `EmptyState` draws one standard layout on iOS, Android, macOS and web. Do not build an empty
@@ -451,6 +483,7 @@ Run this punch list on the screen (it's also a good reviewer checklist):
451
483
 
452
484
  - [ ] **One** primary action, on the one `accentPrimary` surface?
453
485
  - [ ] All four states designed — loading / empty / error / loaded (empty & error have an action)?
486
+ - [ ] Loading state matches the loaded layout (a content-shaped `Skeleton` where the layout is known)? For a redesign, was the existing loading state reviewed against the new layout? A static screen has none?
454
487
  - [ ] Every value a token — zero literal hex / off-scale spacing / invented type sizes?
455
488
  - [ ] Is every user-facing string routed through a copy key with a sensible fallback (`copyOf(key, fallback)` backed by your own `COPY` map; worker strings via `onAppear` params) — none baked directly into the descriptor?
456
489
  - [ ] When a business book exists, do those fallbacks use the site's OWN words (`book.copy`) rather than stock phrases — and in `book.copy.language`?
package/guides/index.json CHANGED
@@ -24,6 +24,7 @@
24
24
  "To SELL an App Store item, a screen composes `createIapCommerce` from `@himalaya/iap-checkout` once per worker (its catalog pricing plus the shared checkout, with the vertical's order adapter) and binds its Get access action with `commerce.purchaseFields`: the order, Apple's sheet, proof delivery, finishing and the access read all happen inside it. Never call StoreKit (`purchaseForVerification`), `finishTransaction` or the payment client from a worker, never fall back to Wix checkout for an App Store item, and show access only when it reports `accessReady`. Purchasing stays unavailable until Wix provides its routes and mapping.",
25
25
  "Keep structure descriptor-authored: use FragmentSlot for enumerable variants and bound list/grid templates for repetition. Use WorkerSubtree only for runtime-unknowable structure, with an explicit screen allowlist, policy budgets, and shipped fallback.",
26
26
  "Bindings ($service:<svc>.<path>, $item.<field>, $flag:<key>) are plain strings in ANY prop — INCLUDING Modifiers token refs (background, color, tint), so a worker can drive per-item theming, not just text. They CANNOT target strict-enum fields (style, size, alignment, severity, presentation, …) — those fail at render, not validation.",
27
+ "A screen that loads data asynchronously ships a designed LOADING state with it — a `Skeleton` shaped like the loaded layout when that layout is known (`StatefulSection` or `ActivityIndicator` when they fit the behavior better) — and a REDESIGN of a data screen reviews and updates that loading state in the same change, so it never shows the old layout. Loading is its own state, never the empty or the error state; a static screen gets no loading state (`himi docs designing-himi-screens` §5).",
27
28
  "Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
28
29
  "Declare every capability a worker uses in TWO places: the calling screen's logicBundle.declaredCapabilities (wix_platform, stream, camera, location, …) — undeclared calls are blocked — AND config.ts `capabilities`, the app-level list a binary is built with. The `capabilities` guide shows each id's import, what the binary needs and which shells serve it; validate flags an unknown id, an undeclared call, and (with `shell` in himalaya.content.json) an id that shell's binary does not serve. A capability with a native footprint (camera, location, push, …) only arrives with a new binary — an over-the-air push cannot add it. EXCEPTION: the Wix REST bridge (wix_api — the @himalaya/wix-* clients / wixApi.call) is ungated today (installed unconditionally), so it works WITHOUT a declaration; declaring wix_api is still recommended — it is where the platform is heading, and it documents what your worker touches.",
29
30
  "`validate`/`render` prove a screen is WELL-FORMED and, by default, paint its INITIAL state — NOT that the app's LOGIC is correct. A wrong reducer (a game that never ends, a bad total) passes both with ok:true. Run `himi test`: it needs NO test files — it boots each screen's real worker and dispatches every action your descriptor already declares, then reports anything that threw, stayed stuck on `loading`, or leaked a NaN/undefined/`$service:` token into visible text. It crawls on HERMETIC DATA by default (zero network): your `dev/net-mocks.json` first, then your config's CMS `seed:` rows, then a canned fixture of frozen real vertical responses shipped with the CLI — so the fetch-SUCCESS mapping path runs too, where data-shape bugs actually live (canned data exercises code paths, not API-contract truth). Two boot-only HOSTILE passes (every list empty; every request 500) then check the empty/outage day — their findings are errors, not warnings. It also runs the worker in a DEVICE-PARITY sandbox (no setTimeout/setInterval, as on JSC/WebView), so a bundle that would only die on the device fails locally instead — e.g. `svc.actions.load(…)` (actions are DIRECT methods; `.actions` is undefined on device). (You are NOT limited to the crawled states either: rendering with `--live` runs the worker's REAL fetches against live Wix — a minted anonymous visitor token from the app's clientId — so a render CAN prove real-data binding locally with no VPN/push; and injecting `state` (`{\"<svc>\":{…}}`) renders a specific mid-flow state without the network.)",