@hitslop/runtime 0.1.1 → 0.2.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/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";
@@ -23,6 +23,10 @@ type Options<T> = {
23
23
  onRevision: (revision: string | null) => void;
24
24
  onSource: (source: Source) => void;
25
25
  onError: (message: string | null) => void;
26
+ onStatus?: (state: {
27
+ isDirty: boolean;
28
+ isSaving: boolean;
29
+ }) => void;
26
30
  };
27
31
  export declare class JsonPersister<T> {
28
32
  private options;
@@ -35,6 +39,9 @@ export declare class JsonPersister<T> {
35
39
  private localVersion;
36
40
  private draining;
37
41
  private operations;
42
+ private failure;
43
+ private status;
44
+ flush(): Promise<void>;
38
45
  constructor(options: Options<T>);
39
46
  localChanged(json: string, value: T): void;
40
47
  reload(): Promise<void>;
@@ -9,6 +9,26 @@ export class JsonPersister {
9
9
  localVersion = 0;
10
10
  draining = null;
11
11
  operations = Promise.resolve(undefined);
12
+ failure = null;
13
+ status() { this.options.onStatus?.({ isDirty: this.pending !== null || this.writing, isSaving: this.writing }); }
14
+ async flush() {
15
+ const retry = this.stopped;
16
+ await this.operations;
17
+ if (this.stopped && !retry && this.failure)
18
+ throw this.failure;
19
+ if (!this.loaded) {
20
+ if (!this.pending)
21
+ return;
22
+ throw new Error("Document data has not loaded");
23
+ }
24
+ this.stopped = false;
25
+ this.failure = null;
26
+ this.requestDrain();
27
+ while (this.draining)
28
+ await this.draining;
29
+ if (this.failure)
30
+ throw this.failure;
31
+ }
12
32
  constructor(options) {
13
33
  this.options = options;
14
34
  this.lastPersistedJson = options.fallback.json;
@@ -21,10 +41,12 @@ export class JsonPersister {
21
41
  this.pending = null;
22
42
  this.stopped = false;
23
43
  this.options.onError(null);
44
+ this.status();
24
45
  return;
25
46
  }
26
47
  this.pending = { json, value };
27
48
  this.stopped = false;
49
+ this.status();
28
50
  this.requestDrain();
29
51
  }
30
52
  reload() {
@@ -60,6 +82,7 @@ export class JsonPersister {
60
82
  this.adopt(result.value, result.revision, wasLoaded ? "external" : "package");
61
83
  }
62
84
  this.options.onError(null);
85
+ this.status();
63
86
  }
64
87
  catch (error) {
65
88
  const message = this.message(error);
@@ -94,6 +117,7 @@ export class JsonPersister {
94
117
  return;
95
118
  this.draining = this.schedule(() => this.drainLoop()).finally(() => {
96
119
  this.draining = null;
120
+ this.status();
97
121
  if (this.loaded && this.pending && !this.stopped)
98
122
  this.requestDrain();
99
123
  });
@@ -102,6 +126,8 @@ export class JsonPersister {
102
126
  if (!this.loaded || this.stopped)
103
127
  return;
104
128
  this.writing = true;
129
+ this.status();
130
+ let conflicts = 0;
105
131
  try {
106
132
  while (this.pending && !this.stopped) {
107
133
  const snapshot = this.pending;
@@ -117,9 +143,11 @@ export class JsonPersister {
117
143
  this.options.onRevision(result.revision);
118
144
  this.options.onSource("app");
119
145
  this.options.onError(null);
146
+ this.failure = null;
147
+ conflicts = 0;
120
148
  }
121
149
  catch (error) {
122
- if (this.message(error).includes("revision_conflict")) {
150
+ if (error && typeof error === "object" && "code" in error && error.code === "revision_conflict" && conflicts++ === 0) {
123
151
  try {
124
152
  const result = await this.options.io.read();
125
153
  this.revision = result.revision;
@@ -131,6 +159,7 @@ export class JsonPersister {
131
159
  catch (readError) {
132
160
  this.pending ??= snapshot;
133
161
  this.stopped = true;
162
+ this.failure = new Error(this.message(readError));
134
163
  this.options.onError(this.message(readError));
135
164
  continue;
136
165
  }
@@ -139,12 +168,14 @@ export class JsonPersister {
139
168
  // and re-arms persistence without losing any fields.
140
169
  this.pending ??= snapshot;
141
170
  this.stopped = true;
171
+ this.failure = new Error(this.message(error));
142
172
  this.options.onError(this.message(error));
143
173
  }
144
174
  }
145
175
  }
146
176
  finally {
147
177
  this.writing = false;
178
+ this.status();
148
179
  }
149
180
  }
150
181
  adopt(value, revision, source) {
@@ -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.1",
3
+ "version": "0.2.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.2.0" },
22
24
  "devDependencies": { "typescript": "^7.0.2" }
23
25
  }