@ashley-shrok/viewmodel-shell 0.3.5 → 0.3.6

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +55 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -192,6 +192,10 @@ export interface ShellOptions {
192
192
  getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
193
193
  /** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
194
194
  onRedirect?: (url: string) => void;
195
+ /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
196
+ * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
197
+ * omitting nextPollIn when no pollInterval is configured. */
198
+ pollInterval?: number;
195
199
  }
196
200
 
197
201
  export interface ShellSideEffect {
@@ -208,17 +212,21 @@ export interface ShellResponse {
208
212
  redirect?: string;
209
213
  /** Applied in order before redirect or re-render. */
210
214
  sideEffects?: ShellSideEffect[];
215
+ /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
216
+ nextPollIn?: number;
211
217
  }
212
218
 
213
219
  export class ViewModelShell {
214
220
  private currentVm: ViewNode | null = null;
215
221
  private currentState: unknown = null;
216
222
  private dispatching = false;
223
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
217
224
 
218
225
  constructor(private options: ShellOptions) {}
219
226
 
220
227
  async load(params?: Record<string, string>): Promise<void> {
221
228
  const { endpoint, adapter, onError, onLoading } = this.options;
229
+ this.stopPolling();
222
230
  try {
223
231
  onLoading?.(true);
224
232
  const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
@@ -229,6 +237,7 @@ export class ViewModelShell {
229
237
  this.currentVm = body.vm;
230
238
  this.currentState = body.state;
231
239
  adapter.render(body.vm, (action) => this.dispatch(action));
240
+ this.schedulePoll(body.nextPollIn);
232
241
  } catch (err) {
233
242
  const error = err instanceof Error ? err : new Error(String(err));
234
243
  onError ? onError(error) : console.error("[ViewModelShell]", error);
@@ -237,9 +246,9 @@ export class ViewModelShell {
237
246
  }
238
247
  }
239
248
 
240
- async dispatch(action: ActionEvent): Promise<void> {
249
+ async dispatch(action: ActionEvent, silent = false): Promise<void> {
241
250
  if (this.dispatching) return;
242
- const { actionEndpoint, adapter, onError, onLoading } = this.options;
251
+ const { actionEndpoint, onError, onLoading } = this.options;
243
252
  if (this.currentState === null) {
244
253
  const err = new Error(
245
254
  `Cannot dispatch '${action.name}' before initial load completes. ` +
@@ -250,7 +259,7 @@ export class ViewModelShell {
250
259
  }
251
260
  try {
252
261
  this.dispatching = true;
253
- onLoading?.(true);
262
+ if (!silent) onLoading?.(true);
254
263
  const form = new FormData();
255
264
  form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
256
265
  form.append("_state", JSON.stringify(this.currentState));
@@ -266,40 +275,58 @@ export class ViewModelShell {
266
275
  body: form,
267
276
  });
268
277
  if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
269
- const body = (await res.json()) as ShellResponse;
270
- for (const effect of body.sideEffects ?? []) {
271
- if (effect.type === "set-local-storage" && effect.key != null) {
272
- localStorage.setItem(effect.key, effect.value ?? "");
273
- } else if (effect.type === "set-session-storage" && effect.key != null) {
274
- sessionStorage.setItem(effect.key, effect.value ?? "");
275
- }
276
- // unknown types silently ignored — forward-compatible
277
- }
278
- if (body.redirect) {
279
- if (this.options.onRedirect) {
280
- this.options.onRedirect(body.redirect);
281
- } else {
282
- window.location.href = body.redirect;
283
- }
284
- return;
285
- }
286
- this.currentVm = body.vm;
287
- this.currentState = body.state;
288
- adapter.render(body.vm, (a) => this.dispatch(a));
278
+ this.processResponse((await res.json()) as ShellResponse);
289
279
  } catch (err) {
290
280
  const error = err instanceof Error ? err : new Error(String(err));
291
281
  onError ? onError(error) : console.error("[ViewModelShell]", error);
292
282
  } finally {
293
283
  this.dispatching = false;
294
- onLoading?.(false);
284
+ if (!silent) onLoading?.(false);
295
285
  }
296
286
  }
297
287
 
298
- getCurrentVm(): ViewNode | null {
299
- return this.currentVm;
288
+ /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
289
+ push(response: ShellResponse): void {
290
+ if (this.dispatching) return;
291
+ this.processResponse(response);
292
+ }
293
+
294
+ stopPolling(): void {
295
+ if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
296
+ }
297
+
298
+ getCurrentVm(): ViewNode | null { return this.currentVm; }
299
+ getCurrentState(): unknown { return this.currentState; }
300
+
301
+ private processResponse(body: ShellResponse): void {
302
+ for (const effect of body.sideEffects ?? []) {
303
+ if (effect.type === "set-local-storage" && effect.key != null) {
304
+ localStorage.setItem(effect.key, effect.value ?? "");
305
+ } else if (effect.type === "set-session-storage" && effect.key != null) {
306
+ sessionStorage.setItem(effect.key, effect.value ?? "");
307
+ }
308
+ }
309
+ if (body.redirect) {
310
+ if (this.options.onRedirect) {
311
+ this.options.onRedirect(body.redirect);
312
+ } else {
313
+ window.location.href = body.redirect;
314
+ }
315
+ return;
316
+ }
317
+ this.currentVm = body.vm!;
318
+ this.currentState = body.state;
319
+ this.options.adapter.render(body.vm!, (a) => this.dispatch(a));
320
+ this.schedulePoll(body.nextPollIn);
300
321
  }
301
322
 
302
- getCurrentState(): unknown {
303
- return this.currentState;
323
+ private schedulePoll(nextPollIn?: number): void {
324
+ const delay = nextPollIn ?? this.options.pollInterval;
325
+ if (delay == null) return;
326
+ if (this.pollTimer) clearTimeout(this.pollTimer);
327
+ this.pollTimer = setTimeout(() => {
328
+ this.pollTimer = null;
329
+ this.dispatch({ name: "poll" }, true);
330
+ }, delay);
304
331
  }
305
332
  }