@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.
- package/package.json +1 -1
- 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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
}
|