@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 +2 -0
- package/dist/adapter.js +2 -0
- package/dist/bridge-call.d.ts +3 -0
- package/dist/bridge-call.js +15 -0
- package/dist/capture.d.ts +61 -0
- package/dist/capture.js +185 -0
- package/dist/change-events.d.ts +2 -0
- package/dist/change-events.js +20 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/host-bridge.d.ts +14 -0
- package/dist/host-bridge.js +73 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +8 -7
- package/dist/json-persister.d.ts +7 -0
- package/dist/json-persister.js +32 -1
- package/dist/latest-task.d.ts +8 -0
- package/dist/latest-task.js +26 -0
- package/dist/lifecycle.d.ts +8 -0
- package/dist/lifecycle.js +13 -0
- package/dist/media-picker.js +4 -1
- package/dist/sql.d.ts +2 -2
- package/dist/theme-reload.d.ts +2 -0
- package/dist/theme-reload.js +27 -0
- package/dist/theme.d.ts +8 -0
- package/dist/theme.js +19 -0
- package/dist/types.d.ts +9 -11
- package/package.json +4 -2
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 {};
|
package/dist/capture.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -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?:
|
|
17
|
-
execute: (sql: string, params?:
|
|
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
|
|
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
|
-
|
|
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";
|
package/dist/json-persister.d.ts
CHANGED
|
@@ -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>;
|
package/dist/json-persister.js
CHANGED
|
@@ -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 (
|
|
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;
|
package/dist/media-picker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Framework-agnostic helpers shared by the media store adapters
|
|
2
|
-
// (@hitslop/svelte
|
|
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:
|
|
3
|
+
export declare function sql(strings: TemplateStringsArray, ...values: SQLValue[]): SlopStatement;
|
|
@@ -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
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
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?:
|
|
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?:
|
|
26
|
-
execute(sql: string, params?:
|
|
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?:
|
|
47
|
-
execute: (sql: string, parameters?:
|
|
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.
|
|
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
|
}
|