@ashley-shrok/viewmodel-shell 0.3.4 → 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 +64 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.3.4",
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,17 @@ 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;
199
+ }
200
+
201
+ export interface ShellSideEffect {
202
+ /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
203
+ type: string;
204
+ key?: string;
205
+ value?: string;
195
206
  }
196
207
 
197
208
  export interface ShellResponse {
@@ -199,17 +210,23 @@ export interface ShellResponse {
199
210
  state: unknown;
200
211
  /** When set, the shell navigates to this URL instead of re-rendering. */
201
212
  redirect?: string;
213
+ /** Applied in order before redirect or re-render. */
214
+ sideEffects?: ShellSideEffect[];
215
+ /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
216
+ nextPollIn?: number;
202
217
  }
203
218
 
204
219
  export class ViewModelShell {
205
220
  private currentVm: ViewNode | null = null;
206
221
  private currentState: unknown = null;
207
222
  private dispatching = false;
223
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
208
224
 
209
225
  constructor(private options: ShellOptions) {}
210
226
 
211
227
  async load(params?: Record<string, string>): Promise<void> {
212
228
  const { endpoint, adapter, onError, onLoading } = this.options;
229
+ this.stopPolling();
213
230
  try {
214
231
  onLoading?.(true);
215
232
  const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
@@ -220,6 +237,7 @@ export class ViewModelShell {
220
237
  this.currentVm = body.vm;
221
238
  this.currentState = body.state;
222
239
  adapter.render(body.vm, (action) => this.dispatch(action));
240
+ this.schedulePoll(body.nextPollIn);
223
241
  } catch (err) {
224
242
  const error = err instanceof Error ? err : new Error(String(err));
225
243
  onError ? onError(error) : console.error("[ViewModelShell]", error);
@@ -228,9 +246,9 @@ export class ViewModelShell {
228
246
  }
229
247
  }
230
248
 
231
- async dispatch(action: ActionEvent): Promise<void> {
249
+ async dispatch(action: ActionEvent, silent = false): Promise<void> {
232
250
  if (this.dispatching) return;
233
- const { actionEndpoint, adapter, onError, onLoading } = this.options;
251
+ const { actionEndpoint, onError, onLoading } = this.options;
234
252
  if (this.currentState === null) {
235
253
  const err = new Error(
236
254
  `Cannot dispatch '${action.name}' before initial load completes. ` +
@@ -241,7 +259,7 @@ export class ViewModelShell {
241
259
  }
242
260
  try {
243
261
  this.dispatching = true;
244
- onLoading?.(true);
262
+ if (!silent) onLoading?.(true);
245
263
  const form = new FormData();
246
264
  form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
247
265
  form.append("_state", JSON.stringify(this.currentState));
@@ -257,32 +275,58 @@ export class ViewModelShell {
257
275
  body: form,
258
276
  });
259
277
  if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
260
- const body = (await res.json()) as ShellResponse;
261
- if (body.redirect) {
262
- if (this.options.onRedirect) {
263
- this.options.onRedirect(body.redirect);
264
- } else {
265
- window.location.href = body.redirect;
266
- }
267
- return;
268
- }
269
- this.currentVm = body.vm;
270
- this.currentState = body.state;
271
- adapter.render(body.vm, (a) => this.dispatch(a));
278
+ this.processResponse((await res.json()) as ShellResponse);
272
279
  } catch (err) {
273
280
  const error = err instanceof Error ? err : new Error(String(err));
274
281
  onError ? onError(error) : console.error("[ViewModelShell]", error);
275
282
  } finally {
276
283
  this.dispatching = false;
277
- onLoading?.(false);
284
+ if (!silent) onLoading?.(false);
278
285
  }
279
286
  }
280
287
 
281
- getCurrentVm(): ViewNode | null {
282
- 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);
283
321
  }
284
322
 
285
- getCurrentState(): unknown {
286
- 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);
287
331
  }
288
332
  }