@hitslop/runtime 0.1.2 → 0.3.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.
package/README.md CHANGED
@@ -14,7 +14,16 @@ ready();
14
14
  Use `@hitslop/runtime/adapter` only when implementing a framework adapter; it
15
15
  exports the shared JSON persister and safe media helpers.
16
16
 
17
- Documentation: [Architecture](https://github.com/hitslop/hitslop/blob/main/docs/architecture.md) ·
18
- [Storage](https://github.com/hitslop/hitslop/blob/main/docs/storage.md)
17
+ `JsonPersister` batches idle writes for 150 ms, with a one-second maximum wait.
18
+ Adapters supply detached snapshots and validate values at their I/O boundaries.
19
+ `flush()` bypasses scheduling and drains pending writes; errors retain their
20
+ original identity and code. Register adapter flushers with `registerFlush` so
21
+ the host can await state that has not reached the bridge yet. Keep that
22
+ registration until teardown's final save succeeds.
23
+ The persister's `onError` callback receives `Error | null`; derive display text
24
+ from the error's `message` rather than replacing the error object.
25
+
26
+ Documentation: [Architecture](https://github.com/hitslop/hitslop/blob/master/docs/architecture.md) ·
27
+ [Storage](https://github.com/hitslop/hitslop/blob/master/docs/storage.md)
19
28
 
20
29
  MIT © 2026 hitSlop contributors.
package/dist/adapter.d.ts CHANGED
@@ -3,4 +3,6 @@
3
3
  * import `slop`, `ready`, `capture`, and `sql` from `@hitslop/runtime` instead.
4
4
  */
5
5
  export { JsonPersister, type JsonSnapshot } from "./json-persister.js";
6
+ export { registerFlush } from "./lifecycle.js";
7
+ export { LatestTask } from "./latest-task.js";
6
8
  export { chooseLocalFile, fileToBase64, mediaSourceURL, safeMediaName } from "./media-picker.js";
package/dist/adapter.js CHANGED
@@ -3,4 +3,6 @@
3
3
  * import `slop`, `ready`, `capture`, and `sql` from `@hitslop/runtime` instead.
4
4
  */
5
5
  export { JsonPersister } from "./json-persister.js";
6
+ export { registerFlush } from "./lifecycle.js";
7
+ export { LatestTask } from "./latest-task.js";
6
8
  export { chooseLocalFile, fileToBase64, mediaSourceURL, safeMediaName } from "./media-picker.js";
@@ -0,0 +1,3 @@
1
+ import { type BridgeMethod, type BridgeParams, type BridgeResult } from "@hitslop/schema/bridge";
2
+ /** Bind each request to its response schema at the untyped WebKit boundary. */
3
+ export declare function createBridgeCall(postMessage: (request: unknown) => Promise<unknown>): <M extends BridgeMethod>(method: M, params: NoInfer<BridgeParams<M>>) => Promise<BridgeResult<M>>;
@@ -0,0 +1,15 @@
1
+ import { BridgeMethods, BridgeReplySchema } from "@hitslop/schema/bridge";
2
+ import { validate } from "@hitslop/schema/validation";
3
+ import { SlopError } from "./errors.js";
4
+ /** Bind each request to its response schema at the untyped WebKit boundary. */
5
+ export function createBridgeCall(postMessage) {
6
+ return async (method, params) => {
7
+ validate(BridgeMethods[method].params, params);
8
+ const reply = validate(BridgeReplySchema, await postMessage({ method, ...params }));
9
+ if (!reply.ok)
10
+ throw new SlopError(reply.error.code, reply.error.message);
11
+ // TS loses the indexed method/result correlation when selecting the schema;
12
+ // the selected validator is the runtime proof for this one boundary cast.
13
+ return validate(BridgeMethods[method].response, reply.value);
14
+ };
15
+ }
@@ -0,0 +1,61 @@
1
+ export type CaptureMode = "preview" | "export" | "icon";
2
+ type Target = {
3
+ element: HTMLElement;
4
+ prepare: () => void | Promise<void>;
5
+ restore: () => void | Promise<void>;
6
+ };
7
+ export declare function createCaptureController(): {
8
+ registerTarget(kind: "icon" | "export", target: Target): () => void;
9
+ onPrepare(handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>): () => void;
10
+ begin(token: string, mode: CaptureMode, options?: {
11
+ blockInteraction?: boolean;
12
+ }): Promise<{
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ dedicated: boolean;
18
+ }>;
19
+ measure: (token: string) => {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height: number;
24
+ dedicated: boolean;
25
+ };
26
+ settle: (token: string) => Promise<void>;
27
+ restore: (token: string) => Promise<void>;
28
+ };
29
+ declare global {
30
+ interface Window {
31
+ __hitslopCapture?: ReturnType<typeof createCaptureController>;
32
+ }
33
+ }
34
+ export declare function captureController(): {
35
+ registerTarget(kind: "icon" | "export", target: Target): () => void;
36
+ onPrepare(handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>): () => void;
37
+ begin(token: string, mode: CaptureMode, options?: {
38
+ blockInteraction?: boolean;
39
+ }): Promise<{
40
+ x: number;
41
+ y: number;
42
+ width: number;
43
+ height: number;
44
+ dedicated: boolean;
45
+ }>;
46
+ measure: (token: string) => {
47
+ x: number;
48
+ y: number;
49
+ width: number;
50
+ height: number;
51
+ dedicated: boolean;
52
+ };
53
+ settle: (token: string) => Promise<void>;
54
+ restore: (token: string) => Promise<void>;
55
+ };
56
+ export declare const capture: {
57
+ isRenderer: () => boolean;
58
+ registerTarget: (kind: "icon" | "export", target: Target) => () => void;
59
+ onPrepare: (handler: (mode: CaptureMode, signal: AbortSignal) => void | Promise<void>) => () => void;
60
+ };
61
+ export {};
@@ -0,0 +1,185 @@
1
+ const interactionEvents = ["pointerdown", "click", "keydown", "wheel", "touchstart"];
2
+ const timeoutMS = 10_000;
3
+ export function createCaptureController() {
4
+ const targets = new Map();
5
+ const preparations = new Set();
6
+ const states = new Map();
7
+ const bounded = async (work, signal) => {
8
+ let timer;
9
+ const timeout = new Promise((_, reject) => {
10
+ timer = setTimeout(() => reject(new Error("Capture timed out waiting for content, fonts, images, or stable layout")), timeoutMS);
11
+ });
12
+ try {
13
+ signal.throwIfAborted();
14
+ const result = await Promise.race([work, timeout]);
15
+ signal.throwIfAborted();
16
+ return result;
17
+ }
18
+ finally {
19
+ clearTimeout(timer);
20
+ }
21
+ };
22
+ const measure = (token) => {
23
+ const target = states.get(token)?.target?.element;
24
+ if (target) {
25
+ const rect = target.getBoundingClientRect();
26
+ return { x: rect.x, y: rect.y, width: Math.ceil(rect.width), height: Math.ceil(Math.max(rect.height, target.scrollHeight)), dedicated: true };
27
+ }
28
+ const root = document.documentElement;
29
+ const body = document.body;
30
+ return { x: 0, y: 0, width: window.innerWidth, height: Math.ceil(Math.max(root.scrollHeight, root.offsetHeight, root.clientHeight, body?.scrollHeight ?? 0, body?.offsetHeight ?? 0)), dedicated: false };
31
+ };
32
+ const settle = async (token) => {
33
+ const state = states.get(token);
34
+ if (!state)
35
+ throw new Error("Capture session is no longer active");
36
+ await bounded((async () => {
37
+ await document.fonts.ready;
38
+ state.controller.signal.throwIfAborted();
39
+ const root = state.target?.element ?? document;
40
+ const images = [...root.querySelectorAll("img")].filter(image => image.getClientRects().length > 0);
41
+ for (const image of images) {
42
+ if (!state.imageLoading.has(image))
43
+ state.imageLoading.set(image, image.getAttribute("loading"));
44
+ image.loading = "eager";
45
+ }
46
+ await Promise.all(images.map(image => image.decode()));
47
+ let previous = "";
48
+ let stable = 0;
49
+ while (stable < 3) {
50
+ await new Promise(resolve => setTimeout(resolve, 40));
51
+ state.controller.signal.throwIfAborted();
52
+ const next = JSON.stringify(measure(token));
53
+ stable = next === previous ? stable + 1 : 0;
54
+ previous = next;
55
+ }
56
+ })(), state.controller.signal);
57
+ };
58
+ const restore = async (token) => {
59
+ const state = states.get(token);
60
+ if (!state)
61
+ return;
62
+ state.controller.abort();
63
+ // Restore host-owned state even when an author's teardown fails.
64
+ try {
65
+ await bounded(Promise.resolve(state.target?.restore()), new AbortController().signal);
66
+ }
67
+ finally {
68
+ state.target?.element.removeAttribute("data-hitslop-active-target");
69
+ if (state.attribute === null)
70
+ document.documentElement.removeAttribute("data-slop-capture");
71
+ else
72
+ document.documentElement.setAttribute("data-slop-capture", state.attribute);
73
+ state.style.remove();
74
+ for (const [image, loading] of state.imageLoading) {
75
+ if (loading === null)
76
+ image.removeAttribute("loading");
77
+ else
78
+ image.setAttribute("loading", loading);
79
+ }
80
+ for (const event of interactionEvents)
81
+ window.removeEventListener(event, state.block, true);
82
+ state.active?.focus({ preventScroll: true });
83
+ if (state.inputSelection && (state.active instanceof HTMLInputElement || state.active instanceof HTMLTextAreaElement)) {
84
+ const [start, end, direction] = state.inputSelection;
85
+ try {
86
+ state.active.setSelectionRange(start, end, direction ?? undefined);
87
+ }
88
+ catch { /* Non-text inputs. */ }
89
+ }
90
+ else {
91
+ const selection = window.getSelection();
92
+ selection?.removeAllRanges();
93
+ for (const range of state.selection) {
94
+ try {
95
+ selection?.addRange(range);
96
+ }
97
+ catch { /* Detached selection. */ }
98
+ }
99
+ }
100
+ for (const [element, left, top] of state.scroll) {
101
+ element.scrollLeft = left;
102
+ element.scrollTop = top;
103
+ }
104
+ window.scrollTo(...state.windowScroll);
105
+ states.delete(token);
106
+ }
107
+ };
108
+ return {
109
+ registerTarget(kind, target) {
110
+ if (targets.has(kind))
111
+ throw new Error(`Expected one ${kind} capture target`);
112
+ targets.set(kind, target);
113
+ return () => { if (targets.get(kind) === target)
114
+ targets.delete(kind); };
115
+ },
116
+ onPrepare(handler) {
117
+ preparations.add(handler);
118
+ return () => { preparations.delete(handler); };
119
+ },
120
+ async begin(token, mode, options = {}) {
121
+ if (states.size)
122
+ throw new Error("Another capture is already in progress");
123
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
124
+ const selection = window.getSelection();
125
+ const style = document.createElement("style");
126
+ style.textContent = '*{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}html[data-slop-capture="static"] [data-slop-export="hide"]{display:none!important}';
127
+ const state = {
128
+ attribute: document.documentElement.getAttribute("data-slop-capture"), active,
129
+ selection: selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange()) : [],
130
+ scroll: [...document.querySelectorAll("*")].filter(e => e.scrollTop || e.scrollLeft).map(e => [e, e.scrollLeft, e.scrollTop]),
131
+ windowScroll: [window.scrollX, window.scrollY], style,
132
+ block: event => { if (event.isTrusted) {
133
+ event.preventDefault();
134
+ event.stopImmediatePropagation();
135
+ } },
136
+ controller: new AbortController(),
137
+ imageLoading: new Map(),
138
+ };
139
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement)
140
+ state.inputSelection = [active.selectionStart, active.selectionEnd, active.selectionDirection];
141
+ states.set(token, state);
142
+ try {
143
+ if (options.blockInteraction !== false)
144
+ for (const event of interactionEvents)
145
+ window.addEventListener(event, state.block, { capture: true, passive: false });
146
+ document.head.append(style);
147
+ active?.blur();
148
+ document.documentElement.setAttribute("data-slop-capture", mode === "icon" ? "icon" : "static");
149
+ state.target = targets.get(mode === "icon" ? "icon" : "export");
150
+ if (state.target) {
151
+ state.target.element.setAttribute("data-hitslop-active-target", "");
152
+ style.textContent += 'html,body{margin:0!important;padding:0!important;width:100%!important;background:transparent!important}body>:not([data-hitslop-active-target]){display:none!important}';
153
+ }
154
+ if (mode === "icon")
155
+ style.textContent += 'html,body{background:transparent!important}';
156
+ await bounded((async () => {
157
+ await state.target?.prepare();
158
+ state.controller.signal.throwIfAborted();
159
+ for (const prepare of preparations) {
160
+ await prepare(mode, state.controller.signal);
161
+ state.controller.signal.throwIfAborted();
162
+ }
163
+ })(), state.controller.signal);
164
+ window.scrollTo(0, 0);
165
+ await settle(token);
166
+ return measure(token);
167
+ }
168
+ catch (error) {
169
+ await restore(token);
170
+ throw error;
171
+ }
172
+ },
173
+ measure, settle, restore,
174
+ };
175
+ }
176
+ export function captureController() {
177
+ return window.__hitslopCapture ??= createCaptureController();
178
+ }
179
+ export const capture = {
180
+ isRenderer: () => typeof document !== "undefined" && document.documentElement.dataset.slopRenderer === "true",
181
+ registerTarget: (kind, target) => captureController().registerTarget(kind, target),
182
+ onPrepare: (handler) => captureController().onPrepare(handler),
183
+ };
184
+ if (typeof window !== "undefined")
185
+ captureController();
@@ -0,0 +1,2 @@
1
+ import type { SlopChange } from "./types.js";
2
+ export declare function dispatchChange(value: unknown, listeners: Record<SlopChange["kind"], Set<(event: SlopChange) => void>>, report: (error: unknown) => void): void;
@@ -0,0 +1,20 @@
1
+ import { ChangeSchema } from "@hitslop/schema/bridge";
2
+ import { validate } from "@hitslop/schema/validation";
3
+ export function dispatchChange(value, listeners, report) {
4
+ let event;
5
+ try {
6
+ event = validate(ChangeSchema, value);
7
+ }
8
+ catch (error) {
9
+ report(error);
10
+ return;
11
+ }
12
+ for (const callback of [...listeners[event.kind]]) {
13
+ try {
14
+ callback(event);
15
+ }
16
+ catch (error) {
17
+ report(error);
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,5 @@
1
+ import type { BridgeErrorCode } from "@hitslop/schema/bridge";
2
+ export declare class SlopError extends Error {
3
+ readonly code: BridgeErrorCode;
4
+ constructor(code: BridgeErrorCode, message: string);
5
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ export class SlopError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "SlopError";
7
+ }
8
+ }
@@ -0,0 +1,14 @@
1
+ import "./capture.js";
2
+ declare global {
3
+ interface Window {
4
+ webkit: {
5
+ messageHandlers: {
6
+ hitslop: {
7
+ postMessage(request: unknown): Promise<unknown>;
8
+ };
9
+ };
10
+ };
11
+ __hitslopEmit?: (event: unknown) => void;
12
+ __hitslopReloadTheme?: (revision: string) => void;
13
+ }
14
+ }
@@ -0,0 +1,73 @@
1
+ import "./capture.js";
2
+ import { assertJSON } from "@hitslop/schema/validation";
3
+ import { createThemeReload } from "./theme-reload.js";
4
+ import { dispatchChange } from "./change-events.js";
5
+ import { createBridgeCall } from "./bridge-call.js";
6
+ const native = window.webkit.messageHandlers.hitslop;
7
+ const invoke = createBridgeCall(request => native.postMessage(request));
8
+ const pending = new Set();
9
+ const listeners = { json: new Set(), sqlite: new Set(), media: new Set() };
10
+ let guestReady = false;
11
+ let readySent = false;
12
+ let readyScheduled = false;
13
+ async function drain() { while (pending.size)
14
+ await Promise.all([...pending]); }
15
+ function call(method, params) {
16
+ const result = invoke(method, params);
17
+ pending.add(result);
18
+ void result.then(() => { pending.delete(result); scheduleReady(); }, () => { pending.delete(result); scheduleReady(); });
19
+ return result;
20
+ }
21
+ function scheduleReady() {
22
+ if (!guestReady || readySent || readyScheduled || pending.size)
23
+ return;
24
+ readyScheduled = true;
25
+ const finish = () => {
26
+ if (!readyScheduled || readySent)
27
+ return;
28
+ readyScheduled = false;
29
+ if (pending.size)
30
+ return;
31
+ readySent = true;
32
+ void call("ready", {}).catch(error => console.error("hitSlop ready failed", error));
33
+ window.dispatchEvent(new Event("slop:ready"));
34
+ };
35
+ requestAnimationFrame(() => requestAnimationFrame(finish));
36
+ setTimeout(finish, 100);
37
+ }
38
+ const watch = (kind, callback) => {
39
+ listeners[kind].add(callback);
40
+ return () => { listeners[kind].delete(callback); };
41
+ };
42
+ window.__hitslopReloadTheme = createThemeReload(document);
43
+ window.__hitslopEmit = value => dispatchChange(value, listeners, error => console.error("hitSlop change event failed", error));
44
+ const bridge = {
45
+ info: () => call("host.info", {}),
46
+ flush: drain,
47
+ json: {
48
+ open: (value) => { assertJSON(value); return call("json.open", { value }); },
49
+ read: () => call("json.read", {}),
50
+ write: (value, expectedRevision) => { assertJSON(value); return call("json.write", { value, ...(expectedRevision === undefined ? {} : { expectedRevision }) }); },
51
+ onChange: (callback) => watch("json", callback),
52
+ },
53
+ db: {
54
+ query: (sql, parameters = []) => call("sqlite.query", { sql, parameters }),
55
+ execute: (sql, parameters = []) => call("sqlite.execute", { sql, parameters }),
56
+ transaction: (statements) => call("sqlite.transaction", { statements }),
57
+ onChange: (callback) => watch("sqlite", callback),
58
+ },
59
+ media: {
60
+ open: (name) => call("media.open", { name }),
61
+ write: (name, data, mimeType) => call("media.write", { name, data, mimeType }),
62
+ remove: (name) => call("media.remove", { name }),
63
+ onChange: (callback) => watch("media", callback),
64
+ },
65
+ window: {
66
+ resize: (size) => call("window.resize", size),
67
+ drag: async () => { await call("window.drag", {}); },
68
+ },
69
+ ready: () => { guestReady = true; document.documentElement.dataset.hitslopReady = "true"; scheduleReady(); },
70
+ };
71
+ window.slop = Object.freeze(bridge);
72
+ window.addEventListener("error", (event) => { void call("log", { message: event.message }).catch(() => undefined); });
73
+ window.addEventListener("unhandledrejection", (event) => { void call("log", { message: String(event.reason) }).catch(() => undefined); });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
- import type { SlopHost, SlopStatement, SlopWindowSize } from "./types.ts";
1
+ import type { SlopHost, SlopStatement, SlopWindowSize, SQLValue } from "./types.ts";
2
2
  export type { SlopChange, SlopHost, SlopMediaSnapshot, SlopSnapshot, SlopStatement, SlopStoreKind, SlopWindowSize, WindowSlop } from "./types.ts";
3
3
  export { sql } from "./sql.js";
4
+ export { flush } from "./lifecycle.js";
5
+ export { SlopError } from "./errors.js";
6
+ export declare function hostInfo(): Promise<{
7
+ protocolVersion: 1;
8
+ capabilities: string[];
9
+ }>;
4
10
  export declare function installHost(host: SlopHost): () => void;
5
11
  export declare function getHost(): SlopHost;
6
12
  export declare const slop: {
@@ -13,8 +19,8 @@ export declare const slop: {
13
19
  onChange: (callback: Parameters<SlopHost["watch"]>[1]) => () => void;
14
20
  };
15
21
  db: {
16
- query: <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;
17
- execute: (sql: string, params?: unknown[]) => Promise<number>;
22
+ query: <T = Record<string, unknown>>(sql: string, params?: SQLValue[]) => Promise<T[]>;
23
+ execute: (sql: string, params?: SQLValue[]) => Promise<number>;
18
24
  transaction: (statements: SlopStatement[]) => Promise<number>;
19
25
  onChange: (callback: Parameters<SlopHost["watch"]>[1]) => () => void;
20
26
  };
@@ -34,6 +40,4 @@ export declare const slop: {
34
40
  };
35
41
  };
36
42
  export declare function ready(): void;
37
- export declare const capture: {
38
- isRenderer: () => boolean;
39
- };
43
+ export { capture, type CaptureMode } from "./capture.js";
package/dist/index.js CHANGED
@@ -1,4 +1,11 @@
1
1
  export { sql } from "./sql.js";
2
+ export { flush } from "./lifecycle.js";
3
+ export { SlopError } from "./errors.js";
4
+ export async function hostInfo() {
5
+ if (typeof window === "undefined" || !window.slop?.info)
6
+ throw new Error("Host information is unavailable");
7
+ return window.slop.info();
8
+ }
2
9
  let configuredHost;
3
10
  const fromBridge = (bridge) => ({
4
11
  query: (sql, params = []) => bridge.db.query(sql, params),
@@ -62,10 +69,4 @@ export const slop = {
62
69
  };
63
70
  export function ready() { if (typeof window !== "undefined")
64
71
  window.slop?.ready?.(); }
65
- /// Icon capture support. The native host renders document assets in a
66
- /// hidden WebView with `<html data-slop-renderer="true">` set before any guest
67
- /// code runs; use `capture.isRenderer()` to mount the `data-slop-render`
68
- /// icon target only in that pass and keep it out of the interactive app.
69
- export const capture = {
70
- isRenderer: () => typeof document !== "undefined" && document.documentElement.dataset.slopRenderer === "true",
71
- };
72
+ export { capture } from "./capture.js";
@@ -3,6 +3,10 @@ export type JsonSnapshot<T> = {
3
3
  value: T;
4
4
  };
5
5
  type Source = "package" | "app" | "external";
6
+ type Scheduler = {
7
+ setTimeout: (callback: () => void, delay: number) => unknown;
8
+ clearTimeout: (handle: unknown) => void;
9
+ };
6
10
  type Options<T> = {
7
11
  fallback: JsonSnapshot<T>;
8
12
  io: {
@@ -22,7 +26,12 @@ type Options<T> = {
22
26
  onAdopt: (value: T, source: Source) => void;
23
27
  onRevision: (revision: string | null) => void;
24
28
  onSource: (source: Source) => void;
25
- onError: (message: string | null) => void;
29
+ onError: (error: Error | null) => void;
30
+ onStatus?: (state: {
31
+ isDirty: boolean;
32
+ isSaving: boolean;
33
+ }) => void;
34
+ scheduler?: Scheduler;
26
35
  };
27
36
  export declare class JsonPersister<T> {
28
37
  private options;
@@ -35,14 +44,28 @@ export declare class JsonPersister<T> {
35
44
  private localVersion;
36
45
  private draining;
37
46
  private operations;
47
+ private failure;
48
+ private invalid;
49
+ private lastLocalJson;
50
+ private flushing;
51
+ private debounce;
52
+ private maximumWait;
53
+ private readonly scheduler;
54
+ private status;
55
+ flush(): Promise<void>;
56
+ private flushNow;
38
57
  constructor(options: Options<T>);
39
58
  localChanged(json: string, value: T): void;
59
+ /** An unrepresentable local value must block older queued snapshots too. */
60
+ localInvalid(error: Error): void;
40
61
  reload(): Promise<void>;
41
62
  externalChanged(eventRevision?: string | null): void;
63
+ private cancelTimers;
64
+ private scheduleDrain;
42
65
  private requestDrain;
43
66
  private drainLoop;
44
67
  private adopt;
45
68
  private schedule;
46
- private message;
69
+ private asError;
47
70
  }
48
71
  export {};
@@ -9,23 +9,83 @@ export class JsonPersister {
9
9
  localVersion = 0;
10
10
  draining = null;
11
11
  operations = Promise.resolve(undefined);
12
+ failure = null;
13
+ invalid = null;
14
+ lastLocalJson;
15
+ flushing = null;
16
+ debounce;
17
+ maximumWait;
18
+ scheduler;
19
+ status() { this.options.onStatus?.({ isDirty: this.pending !== null || this.writing || this.invalid !== null, isSaving: this.writing }); }
20
+ flush() {
21
+ if (this.flushing)
22
+ return this.flushing;
23
+ this.cancelTimers();
24
+ this.flushing = this.flushNow().finally(() => { this.flushing = null; });
25
+ return this.flushing;
26
+ }
27
+ async flushNow() {
28
+ const retry = this.stopped;
29
+ await this.operations;
30
+ if (this.invalid)
31
+ throw this.invalid;
32
+ if (this.stopped && !retry && this.failure)
33
+ throw this.failure;
34
+ if (!this.loaded) {
35
+ if (!this.pending)
36
+ return;
37
+ throw this.failure ?? new Error("Document data has not loaded");
38
+ }
39
+ this.stopped = false;
40
+ this.failure = null;
41
+ this.requestDrain();
42
+ while (this.draining)
43
+ await this.draining;
44
+ if (this.failure)
45
+ throw this.failure;
46
+ }
12
47
  constructor(options) {
13
48
  this.options = options;
14
49
  this.lastPersistedJson = options.fallback.json;
50
+ this.lastLocalJson = options.fallback.json;
51
+ this.scheduler = options.scheduler ?? {
52
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
53
+ clearTimeout: handle => clearTimeout(handle),
54
+ };
15
55
  }
16
56
  localChanged(json, value) {
57
+ if (!this.invalid && json === this.lastLocalJson)
58
+ return;
17
59
  this.localVersion += 1;
60
+ this.lastLocalJson = json;
61
+ this.invalid = null;
62
+ this.failure = null;
18
63
  // A write already in flight may change what is persisted, so a reversion to
19
64
  // the previously persisted value still has to remain queued until it lands.
20
65
  if (!this.writing && json === this.lastPersistedJson) {
21
66
  this.pending = null;
67
+ this.cancelTimers();
22
68
  this.stopped = false;
23
69
  this.options.onError(null);
70
+ this.status();
24
71
  return;
25
72
  }
26
73
  this.pending = { json, value };
27
74
  this.stopped = false;
28
- this.requestDrain();
75
+ this.options.onError(null);
76
+ this.status();
77
+ this.scheduleDrain();
78
+ }
79
+ /** An unrepresentable local value must block older queued snapshots too. */
80
+ localInvalid(error) {
81
+ this.localVersion += 1;
82
+ this.invalid = error;
83
+ this.failure = error;
84
+ this.pending = null;
85
+ this.stopped = true;
86
+ this.cancelTimers();
87
+ this.options.onError(error);
88
+ this.status();
29
89
  }
30
90
  reload() {
31
91
  const wasLoaded = this.loaded;
@@ -35,6 +95,9 @@ export class JsonPersister {
35
95
  if (wasLoaded) {
36
96
  this.pending = null;
37
97
  this.stopped = false;
98
+ this.invalid = null;
99
+ this.failure = null;
100
+ this.cancelTimers();
38
101
  }
39
102
  return this.schedule(async () => {
40
103
  try {
@@ -46,24 +109,31 @@ export class JsonPersister {
46
109
  this.revision = result.revision;
47
110
  this.lastPersistedJson = persistedJson;
48
111
  this.options.onRevision(result.revision);
49
- const local = this.options.getLocal();
50
112
  const changedDuringReload = this.localVersion !== requestedAtVersion;
113
+ const local = !wasLoaded || changedDuringReload ? this.options.getLocal() : { json: persistedJson, value: result.value };
51
114
  const localWins = wasLoaded
52
115
  ? changedDuringReload && local.json !== persistedJson
53
116
  : local.json !== this.options.fallback.json;
117
+ // A write that was already running can fail after reload was requested.
118
+ // A successful read still recovers the store from that stopped state.
119
+ this.stopped = false;
120
+ this.failure = null;
121
+ this.invalid = null;
54
122
  if (localWins) {
55
123
  this.pending = local;
56
- this.requestDrain();
124
+ this.scheduleDrain();
57
125
  }
58
126
  else {
59
127
  this.pending = null;
128
+ this.cancelTimers();
60
129
  this.adopt(result.value, result.revision, wasLoaded ? "external" : "package");
61
130
  }
62
131
  this.options.onError(null);
132
+ this.status();
63
133
  }
64
134
  catch (error) {
65
- const message = this.message(error);
66
- this.options.onError(message);
135
+ this.failure = this.asError(error);
136
+ this.options.onError(this.failure);
67
137
  throw error;
68
138
  }
69
139
  });
@@ -85,15 +155,36 @@ export class JsonPersister {
85
155
  this.options.onError(null);
86
156
  }
87
157
  catch (error) {
88
- this.options.onError(this.message(error));
158
+ this.options.onError(this.asError(error));
89
159
  }
90
160
  });
91
161
  }
162
+ cancelTimers() {
163
+ if (this.debounce !== undefined)
164
+ this.scheduler.clearTimeout(this.debounce);
165
+ if (this.maximumWait !== undefined)
166
+ this.scheduler.clearTimeout(this.maximumWait);
167
+ this.debounce = this.maximumWait = undefined;
168
+ }
169
+ scheduleDrain() {
170
+ if (!this.loaded || this.stopped || !this.pending || this.draining)
171
+ return;
172
+ if (this.flushing) {
173
+ this.requestDrain();
174
+ return;
175
+ }
176
+ const drain = () => { this.cancelTimers(); this.requestDrain(); };
177
+ if (this.debounce !== undefined)
178
+ this.scheduler.clearTimeout(this.debounce);
179
+ this.debounce = this.scheduler.setTimeout(drain, 150);
180
+ this.maximumWait ??= this.scheduler.setTimeout(drain, 1_000);
181
+ }
92
182
  requestDrain() {
93
183
  if (this.draining || !this.loaded || this.stopped || !this.pending)
94
184
  return;
95
185
  this.draining = this.schedule(() => this.drainLoop()).finally(() => {
96
186
  this.draining = null;
187
+ this.status();
97
188
  if (this.loaded && this.pending && !this.stopped)
98
189
  this.requestDrain();
99
190
  });
@@ -102,6 +193,8 @@ export class JsonPersister {
102
193
  if (!this.loaded || this.stopped)
103
194
  return;
104
195
  this.writing = true;
196
+ this.status();
197
+ let conflicts = 0;
105
198
  try {
106
199
  while (this.pending && !this.stopped) {
107
200
  const snapshot = this.pending;
@@ -116,10 +209,14 @@ export class JsonPersister {
116
209
  this.lastPersistedJson = snapshot.json;
117
210
  this.options.onRevision(result.revision);
118
211
  this.options.onSource("app");
119
- this.options.onError(null);
212
+ if (!this.invalid) {
213
+ this.options.onError(null);
214
+ this.failure = null;
215
+ }
216
+ conflicts = 0;
120
217
  }
121
218
  catch (error) {
122
- if (this.message(error).includes("revision_conflict")) {
219
+ if (error && typeof error === "object" && "code" in error && error.code === "revision_conflict" && conflicts++ === 0) {
123
220
  try {
124
221
  const result = await this.options.io.read();
125
222
  this.revision = result.revision;
@@ -131,7 +228,8 @@ export class JsonPersister {
131
228
  catch (readError) {
132
229
  this.pending ??= snapshot;
133
230
  this.stopped = true;
134
- this.options.onError(this.message(readError));
231
+ this.failure = this.asError(readError);
232
+ this.options.onError(this.failure);
135
233
  continue;
136
234
  }
137
235
  }
@@ -139,17 +237,20 @@ export class JsonPersister {
139
237
  // and re-arms persistence without losing any fields.
140
238
  this.pending ??= snapshot;
141
239
  this.stopped = true;
142
- this.options.onError(this.message(error));
240
+ this.failure = this.asError(error);
241
+ this.options.onError(this.failure);
143
242
  }
144
243
  }
145
244
  }
146
245
  finally {
147
246
  this.writing = false;
247
+ this.status();
148
248
  }
149
249
  }
150
250
  adopt(value, revision, source) {
151
251
  this.revision = revision;
152
252
  this.lastPersistedJson = JSON.stringify(value);
253
+ this.lastLocalJson = this.lastPersistedJson;
153
254
  this.options.onRevision(revision);
154
255
  this.options.onAdopt(value, source);
155
256
  }
@@ -158,7 +259,7 @@ export class JsonPersister {
158
259
  this.operations = scheduled.catch(() => undefined);
159
260
  return scheduled;
160
261
  }
161
- message(error) {
162
- return error instanceof Error ? error.message : String(error);
262
+ asError(error) {
263
+ return error instanceof Error ? error : new Error(String(error));
163
264
  }
164
265
  }
@@ -0,0 +1,8 @@
1
+ /** Prevent a slow response from replacing a newer result or a disposed view. */
2
+ export declare class LatestTask {
3
+ private generation;
4
+ private disposed;
5
+ run<T>(operation: () => Promise<T>, adopt: (value: T) => void, fail: (error: unknown) => void, finish: () => void): Promise<void>;
6
+ invalidate(): void;
7
+ dispose(): void;
8
+ }
@@ -0,0 +1,26 @@
1
+ /** Prevent a slow response from replacing a newer result or a disposed view. */
2
+ export class LatestTask {
3
+ generation = 0;
4
+ disposed = false;
5
+ async run(operation, adopt, fail, finish) {
6
+ if (this.disposed)
7
+ return;
8
+ const generation = ++this.generation;
9
+ const current = () => !this.disposed && generation === this.generation;
10
+ try {
11
+ const value = await operation();
12
+ if (current())
13
+ adopt(value);
14
+ }
15
+ catch (error) {
16
+ if (current())
17
+ fail(error);
18
+ }
19
+ finally {
20
+ if (current())
21
+ finish();
22
+ }
23
+ }
24
+ invalidate() { this.generation += 1; }
25
+ dispose() { this.disposed = true; this.invalidate(); }
26
+ }
@@ -0,0 +1,8 @@
1
+ /** Adapters register here so a native close/duplicate can await reactive writes. */
2
+ export declare function registerFlush(handler: () => Promise<void>): () => void;
3
+ export declare function flush(): Promise<void>;
4
+ declare global {
5
+ interface Window {
6
+ __hitslopFlush?: () => Promise<void>;
7
+ }
8
+ }
@@ -0,0 +1,13 @@
1
+ const flushers = new Set();
2
+ /** Adapters register here so a native close/duplicate can await reactive writes. */
3
+ export function registerFlush(handler) {
4
+ flushers.add(handler);
5
+ return () => { flushers.delete(handler); };
6
+ }
7
+ export async function flush() {
8
+ await Promise.all([...flushers].map((handler) => handler()));
9
+ if (typeof window !== "undefined")
10
+ await window.slop?.flush?.();
11
+ }
12
+ if (typeof window !== "undefined")
13
+ window.__hitslopFlush = flush;
@@ -1,5 +1,5 @@
1
1
  // Framework-agnostic helpers shared by the media store adapters
2
- // (@hitslop/svelte, @hitslop/react). No reactivity in here: adapters own state.
2
+ // (such as @hitslop/svelte). No reactivity in here: adapters own state.
3
3
  export const safeMediaName = (name, label = "Media") => {
4
4
  if (!/^[a-z][a-z0-9-]{0,63}$/.test(name))
5
5
  throw new Error(`${label} store names must use lowercase letters, numbers, and hyphens`);
@@ -7,6 +7,8 @@ export const safeMediaName = (name, label = "Media") => {
7
7
  };
8
8
  /** Reads a File into the base64 payload expected by `slop.media.write`. */
9
9
  export const fileToBase64 = async (file, label = "file") => {
10
+ if (file.size > 25 * 1024 * 1024)
11
+ throw new Error("Choose a file no larger than 25 MiB");
10
12
  let bytes;
11
13
  try {
12
14
  bytes = new Uint8Array(await file.arrayBuffer());
@@ -33,6 +35,7 @@ export const chooseLocalFile = (accept, onFile) => {
33
35
  if (file)
34
36
  onFile(file);
35
37
  }, { once: true });
38
+ input.addEventListener("cancel", () => input.remove(), { once: true });
36
39
  document.body.append(input);
37
40
  input.click();
38
41
  };
package/dist/sql.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { SlopStatement } from "./types.ts";
1
+ import type { SlopStatement, SQLValue } from "./types.ts";
2
2
  /** Tagged template that turns interpolated values into positional `?` parameters. */
3
- export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): SlopStatement;
3
+ export declare function sql(strings: TemplateStringsArray, ...values: SQLValue[]): SlopStatement;
@@ -0,0 +1,2 @@
1
+ /** Keep the last loaded theme until its latest replacement has loaded. */
2
+ export declare function createThemeReload(document: Pick<Document, "querySelector">): (revision: string) => void;
@@ -0,0 +1,27 @@
1
+ /** Keep the last loaded theme until its latest replacement has loaded. */
2
+ export function createThemeReload(document) {
3
+ let pending;
4
+ return (revision) => {
5
+ pending?.remove();
6
+ pending = undefined;
7
+ const current = document.querySelector('link[data-hitslop-theme]');
8
+ if (!current)
9
+ return;
10
+ const next = current.cloneNode();
11
+ pending = next;
12
+ next.href = `theme.css?revision=${encodeURIComponent(revision)}`;
13
+ next.onload = () => {
14
+ if (pending !== next)
15
+ return;
16
+ pending = undefined;
17
+ current.remove();
18
+ };
19
+ next.onerror = () => {
20
+ if (pending !== next)
21
+ return;
22
+ pending = undefined;
23
+ next.remove();
24
+ };
25
+ current.after(next);
26
+ };
27
+ }
@@ -0,0 +1,8 @@
1
+ /** Plain CSS variables: usable by vanilla-extract or any other styling system. */
2
+ export declare function defineTheme<const Tokens extends Record<string, string>>(tokens: Tokens): Readonly<{
3
+ vars: Readonly<{ readonly [Key in keyof Tokens]: `var(--slop-${string})`; }>;
4
+ css: `:root {
5
+ ${string}
6
+ }
7
+ `;
8
+ }>;
package/dist/theme.js ADDED
@@ -0,0 +1,19 @@
1
+ /** Plain CSS variables: usable by vanilla-extract or any other styling system. */
2
+ export function defineTheme(tokens) {
3
+ const vars = {};
4
+ const declarations = [];
5
+ const names = new Set();
6
+ for (const [key, value] of Object.entries(tokens)) {
7
+ const name = `--slop-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
8
+ if (!/^--slop-[a-z][a-z0-9-]*$/.test(name) || names.has(name))
9
+ throw new Error(`Invalid or duplicate theme token: ${key}`);
10
+ if (!value.trim() || /[{};]|\/\*|<\/style/i.test(value))
11
+ throw new Error(`Invalid theme value for ${key}`);
12
+ names.add(name);
13
+ Object.assign(vars, { [key]: `var(${name})` });
14
+ declarations.push(` ${name}: ${value};`);
15
+ }
16
+ if (!names.size)
17
+ throw new Error("A theme needs at least one token");
18
+ return Object.freeze({ vars: Object.freeze(vars), css: `:root {\n${declarations.join("\n")}\n}\n` });
19
+ }
package/dist/types.d.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  export type SlopStoreKind = "json" | "sqlite" | "media";
2
- export type SlopChange = {
3
- kind: SlopStoreKind;
4
- source: "app" | "external" | "dev";
5
- revision?: string | null;
6
- sequence?: number;
7
- };
2
+ import type { HostInfo, SlopChange, SQLValue } from "@hitslop/schema/bridge";
3
+ export type { HostInfo, SlopChange, SQLValue } from "@hitslop/schema/bridge";
8
4
  export type SlopStatement = {
9
5
  sql: string;
10
- parameters?: unknown[];
6
+ parameters?: SQLValue[];
11
7
  };
12
8
  export type SlopSnapshot<T> = {
13
9
  value: T;
@@ -22,8 +18,8 @@ export type SlopWindowSize = {
22
18
  height: number;
23
19
  };
24
20
  export interface SlopHost {
25
- query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
26
- execute(sql: string, params?: unknown[]): Promise<number>;
21
+ query<T = Record<string, unknown>>(sql: string, params?: SQLValue[]): Promise<T[]>;
22
+ execute(sql: string, params?: SQLValue[]): Promise<number>;
27
23
  transaction(statements: SlopStatement[]): Promise<number>;
28
24
  jsonOpen<T>(initialValue: T): Promise<SlopSnapshot<T>>;
29
25
  jsonRead<T>(): Promise<SlopSnapshot<T>>;
@@ -42,9 +38,10 @@ export interface SlopHost {
42
38
  watch(kind: SlopStoreKind, callback: (event: SlopChange) => void): () => void;
43
39
  }
44
40
  export type WindowSlop = {
41
+ info?: () => Promise<HostInfo>;
45
42
  db: {
46
- query: (sql: string, parameters?: unknown[]) => Promise<unknown[]>;
47
- execute: (sql: string, parameters?: unknown[]) => Promise<number>;
43
+ query: (sql: string, parameters?: SQLValue[]) => Promise<unknown[]>;
44
+ execute: (sql: string, parameters?: SQLValue[]) => Promise<number>;
48
45
  transaction: (statements: SlopStatement[]) => Promise<number>;
49
46
  onChange: (callback: (event: SlopChange) => void) => () => void;
50
47
  };
@@ -71,6 +68,7 @@ export type WindowSlop = {
71
68
  drag?: () => Promise<void>;
72
69
  };
73
70
  ready?: () => void;
71
+ flush?: () => Promise<void>;
74
72
  };
75
73
  declare global {
76
74
  interface Window {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hitslop/runtime",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-neutral browser bridge for hitSlop documents.",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/hitslop/hitslop.git", "directory": "packages/runtime" },
@@ -12,12 +12,14 @@
12
12
  "files": ["dist"],
13
13
  "exports": {
14
14
  ".": "./dist/index.js",
15
- "./adapter": "./dist/adapter.js"
15
+ "./adapter": "./dist/adapter.js",
16
+ "./theme": "./dist/theme.js"
16
17
  },
17
18
  "scripts": {
18
19
  "build": "bun ../../scripts/clean-dist.ts && tsc -p tsconfig.build.json",
19
20
  "check": "tsc -p tsconfig.json",
20
21
  "test": "bun test"
21
22
  },
23
+ "dependencies": { "@hitslop/schema": "^0.3.0" },
22
24
  "devDependencies": { "typescript": "^7.0.2" }
23
25
  }