@panphora/clayjs 0.5.0 → 0.6.1

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
@@ -19,8 +19,10 @@ query params on the script URL:
19
19
  <script src="/clay.js?plugins=sync,cms&exclude=indicator"></script>
20
20
  ```
21
21
 
22
- - `?plugins=` — add optional plugins: `sync`, `cms` (richclay, indicator, sortable, undo load by default in edit mode).
23
- - `?exclude=` — drop a default plugin.
22
+ - `?plugins=` — add optional plugins: `sync`, `cms`, `undo`, `sortable`, `indicator`, `quickcrop`, `wire`.
23
+ Only `richclay` loads by default, and only in edit mode. `cms` brings `quickcrop` with it, because
24
+ the CMS uses it for `data-hcms-crop` image fields.
25
+ - `?exclude=` — drop a plugin that would otherwise load (a default, or one another plugin pulled in).
24
26
  - `?editmode=false` — force view mode (URL param wins over everything below).
25
27
 
26
28
  ## Host attributes
@@ -52,8 +54,8 @@ clay.save();
52
54
 
53
55
  Edit mode exposes `clay.save()` (+ `clay.save.force()`), `clay.getHTML()`, `clay.addDocumentTransform(fn)`,
54
56
  `clay.onSnapshot(fn)`, `clay.toggleEditMode()`, `clay.isEditMode`, `clay.isOwner`, `clay.Mutation`,
55
- `clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay`
56
- when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
57
+ `clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay` /
58
+ `clay.quickcrop` when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
57
59
  `Mutation`, `region`, `ready`); edit-only members are simply absent.
58
60
 
59
61
  ## API
@@ -70,12 +72,18 @@ The contract starts at **0.3.0**: no name below changes without a major version.
70
72
 
71
73
  **`clay.js`** — `ready`, `save()`, `save.force()`, `getHTML()`, `addDocumentTransform(fn)`, `onSnapshot(fn)`,
72
74
  `toggleEditMode()`, `isEditMode`, `isOwner`, `Mutation`, `region`, `cacheBust(el)`, plus `undo` / `cms` / `morph` /
73
- `RichClay` when those plugins load. View mode keeps only the always-available members, as above.
75
+ `RichClay` / `quickcrop` when those plugins load. View mode keeps only the always-available members, as above.
74
76
 
75
77
  `addDocumentTransform(fn)` runs your callback over a detached clone whenever the page
76
78
  prepares to save AND whenever it checks whether anything changed. Keep it pure and
77
79
  repeatable: it is a transform, not a "a save is happening" event.
78
80
 
81
+ `quickcrop(file, options)` opens a crop modal over a File or Blob and resolves
82
+ `{ blob, dataURL, width, height }`, or `null` if the person cancels. `aspect` (a number,
83
+ or null for freeform), `type`, `quality`, `maxWidth`/`maxHeight` and `labels.confirm` are
84
+ the options worth knowing. Its modal and injected stylesheet carry `save-remove`, so they
85
+ never reach the saved file.
86
+
79
87
  `region` is the region-policy model: `resolveRegionPolicy`, `isInert`, `skipForPolicy`,
80
88
  `strictestPolicy`, the `PERSIST` and `REGION_ATTRS` token constants, and the
81
89
  `STRIP_FROM_SAVE`, `FREEZE_SELECTOR` and `STRIP_FROM_COMPARISON` selectors.
package/_headers CHANGED
@@ -1,2 +1,14 @@
1
+ # Cache-Control is declared once, on /*. Two matching blocks do not override,
2
+ # they join: repeating this line under /src/* served every module a doubled
3
+ # max-age, which a cache is free to treat as stale. /src/* inherits it and adds
4
+ # only CORS.
5
+ #
6
+ # No stale-while-revalidate: clay.js imports ~49 unversioned module URLs, each
7
+ # with its own freshness clock, and a view-mode load fetches a different subset
8
+ # than an edit-mode load. A stale window lets one browser hold half the graph
9
+ # from before a deploy and half from after, which fails silently.
10
+ /*
11
+ Cache-Control: public, max-age=600
12
+
1
13
  /src/*
2
14
  Access-Control-Allow-Origin: *
package/clay-events.js CHANGED
@@ -20,7 +20,16 @@
20
20
  });
21
21
  }
22
22
 
23
+ // [onrender] sweeps the whole document the moment that import resolves, and those
24
+ // handlers routinely reach for clay-dom's element helpers (this.val, this.exec,
25
+ // this.nearest). The two satellites are independent dynamic imports with nothing
26
+ // ordering them, so on a cold load events can win and every handler throws
27
+ // "Cannot read properties of undefined". By DOM ready every satellite tag has run,
28
+ // so clay.loaded.dom is present exactly when clay-dom.js is on the page: wait for it
29
+ // then, proceed when it is absent. A clay-dom that failed to load must not take
30
+ // events down with it, so its rejection is swallowed here rather than chained.
23
31
  clay.loaded.events = domReady()
32
+ .then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
24
33
  .then(function () { return import(base + "/src/events/index.js"); })
25
34
  .catch(function (err) { console.error("clay-events failed to load:", err); throw err; });
26
35
  // Mark handled: see clay-ui.js.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * host-meta.js — what this host can do (spec §5).
3
+ *
4
+ * The first discovery client clayjs has had. Not fetched at boot: the first
5
+ * caller is the first file pick, so a page that never uploads never asks.
6
+ *
7
+ * The spec's own rule about what counts as an answer is deliberately strict on
8
+ * the way in and forgiving on the way out. Only a 2xx carrying a JSON object with
9
+ * a numeric `spec` is a capability document. A 404, an HTML error page from a
10
+ * proxy, a redirect, a body that will not parse: all of them mean "bare core
11
+ * host", and a bare core host is fully conforming. Discovery failing must never
12
+ * cost a person their save, so this never throws and never rejects.
13
+ */
14
+
15
+ import { saveToken } from "./host-attrs.js";
16
+
17
+ const META_PATH = "/_/meta";
18
+ const META_TIMEOUT_MS = 6000;
19
+
20
+ // The bare-core-host answer, which is also every failure's answer.
21
+ function bareHost() {
22
+ return { spec: null, extensions: [], document: null };
23
+ }
24
+
25
+ let inFlight = null;
26
+
27
+ /**
28
+ * Ask the host what it supports, once per page.
29
+ *
30
+ * Memoizes the PROMISE rather than the value, so two pickers opened together
31
+ * make one request instead of two and the second waits on the first.
32
+ *
33
+ * @returns {Promise<{spec: ?number, extensions: string[], document: ?Object}>}
34
+ */
35
+ export function hostMeta() {
36
+ if (!inFlight) inFlight = fetchMeta().catch(bareHost);
37
+ return inFlight;
38
+ }
39
+
40
+ /**
41
+ * True when the host announced this capability by name.
42
+ *
43
+ * §5: a client must not infer a host's capabilities any other way. Not from the
44
+ * hostname, not from the port, not by probing a route to see whether it answers.
45
+ *
46
+ * @param {string} name
47
+ * @returns {Promise<boolean>}
48
+ */
49
+ export async function hostSupports(name) {
50
+ const meta = await hostMeta();
51
+ return meta.extensions.includes(name);
52
+ }
53
+
54
+ /** Drop the memoized answer. Tests only. */
55
+ export function resetHostMeta() {
56
+ inFlight = null;
57
+ }
58
+
59
+ async function fetchMeta() {
60
+ const token = saveToken();
61
+ // A host that mints tokens answers discovery per token, because on a sandboxed
62
+ // document the token is the only identity there is: the browser gives it an
63
+ // opaque origin, so it holds no cookie and gets the answer any stranger would.
64
+ // Without this the `document` block, which carries whether this person may
65
+ // upload and how large a file the host takes, is unreachable on exactly the
66
+ // hosts that mint tokens.
67
+ const path = token ? `${META_PATH}/${token}` : META_PATH;
68
+
69
+ const controller = new AbortController();
70
+ const timeoutId = setTimeout(() => controller.abort(), META_TIMEOUT_MS);
71
+ try {
72
+ const res = await fetch(new URL(path, window.location.origin).href, {
73
+ method: "GET",
74
+ // Same rule as the save lane. The token IS the credential, and a host that
75
+ // mints per-document tokens must never send Access-Control-Allow-Credentials
76
+ // back, so asking for cookies gets the answer blocked before it is read.
77
+ credentials: token ? "omit" : "same-origin",
78
+ headers: { "Document-URL": window.location.href },
79
+ cache: "no-store",
80
+ // A redirect is not an answer (§5), and following one would send the
81
+ // Document-URL header somewhere this host did not choose.
82
+ redirect: "manual",
83
+ signal: controller.signal
84
+ });
85
+ if (!res.ok) return bareHost();
86
+ const text = await res.text();
87
+ if (!text) return bareHost();
88
+
89
+ let body;
90
+ try {
91
+ body = JSON.parse(text);
92
+ } catch (_) {
93
+ return bareHost();
94
+ }
95
+ if (!body || typeof body !== "object" || typeof body.spec !== "number") return bareHost();
96
+
97
+ return {
98
+ spec: body.spec,
99
+ extensions: Array.isArray(body.extensions) ? body.extensions : [],
100
+ document: body.document && typeof body.document === "object" ? body.document : null
101
+ };
102
+ } catch (_) {
103
+ return bareHost();
104
+ } finally {
105
+ clearTimeout(timeoutId);
106
+ }
107
+ }
package/src/core/save.js CHANGED
@@ -21,11 +21,23 @@ import {
21
21
  } from "./save-core.js";
22
22
  import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
23
23
  import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
24
+ import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
24
25
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
25
26
 
26
- // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
27
+ // Keep this library's own root state out of the saved bytes.
28
+ //
29
+ // This used to write `savestatus="saved"` onto the clone instead of removing it,
30
+ // which stopped a mid-save "saving" from being baked in but still put a library
31
+ // attribute on disk. It is there today in four LOCAL_APPS documents. These three
32
+ // are this tab's UI truth, re-stamped on every load by edit-mode and by the save
33
+ // lane itself, so a stored copy is at best noise and at worst a lie: a file whose
34
+ // disk bytes say `savestatus="saved"` reads as saved before clayjs has booted.
35
+ //
36
+ // Scoped to the ROOT on purpose. `savestatus` on any other element is an authored
37
+ // attribute that `option:savestatus` reads, and stripping those would delete page
38
+ // content. Both clones get this, so the dirty comparison sees no difference.
27
39
  addDocumentTransform(clone => {
28
- clone.setAttribute('savestatus', 'saved');
40
+ for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
29
41
  });
30
42
 
31
43
  // ============================================
@@ -117,6 +129,37 @@ let lastSavedContents = '';
117
129
  // A save was requested while one was on the wire; run one more when it settles.
118
130
  let pendingSave = false;
119
131
 
132
+ // ============================================
133
+ // AUTOSAVE SUSPENSION
134
+ // ============================================
135
+ //
136
+ // clay.wire holds this for the length of an agent request. The save is
137
+ // last-writer-wins with a backup, so an autosave landing while a local process is
138
+ // writing the same file posts the pre-agent document: the server backs the agent's
139
+ // bytes up and writes the browser's, the watcher's revalidation then fails, and
140
+ // the agent's work exists only in Backups while the page reports success.
141
+ //
142
+ // It suspends AUTOsave only. An explicit savePage — Cmd+S, a [trigger-save]
143
+ // button, or the wire's own pre-send flush — is a deliberate act and still runs.
144
+ //
145
+ // Reference counted, because two overlapping requests each hold it. A save
146
+ // skipped while suspended is replayed on release, or the user's edits would sit
147
+ // unsaved until something else happened to mutate the page.
148
+ let autosaveSuspended = 0;
149
+ let autosaveMissed = false;
150
+
151
+ export function suspendAutosave() {
152
+ autosaveSuspended++;
153
+ }
154
+
155
+ export function resumeAutosave() {
156
+ if (autosaveSuspended === 0) return;
157
+ autosaveSuspended--;
158
+ if (autosaveSuspended > 0 || !autosaveMissed) return;
159
+ autosaveMissed = false;
160
+ savePageThrottled();
161
+ }
162
+
120
163
  function skipped_(msg) {
121
164
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
122
165
  }
@@ -451,6 +494,16 @@ export function savePageThrottled(callback = () => {}) {
451
494
  return Promise.resolve(skipped);
452
495
  }
453
496
 
497
+ // Every autosave path lands here — the mutation-driven one, the [persist] input
498
+ // timer, and live-sync's convergence save after a protected apply — which is
499
+ // why the suspension lives at this one entry rather than at each caller.
500
+ if (autosaveSuspended > 0) {
501
+ autosaveMissed = true;
502
+ const skipped = skipped_('Autosave suspended');
503
+ callback(skipped);
504
+ return Promise.resolve(skipped);
505
+ }
506
+
454
507
  // For autosave: while the page is still settling, content must differ from BOTH
455
508
  // the load-time baseline and the last save, so module setup churn cannot trigger
456
509
  // a save. Once settled, the baseline veto is disarmed and only the last save
@@ -27,6 +27,7 @@
27
27
 
28
28
  import Mutation from './mutation.js';
29
29
  import { isEditMode } from '../core/is-edit-mode.js';
30
+ import { STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
30
31
 
31
32
  let changes = 0;
32
33
  let clearedAt = 0;
@@ -34,6 +35,22 @@ let paused = false;
34
35
  let started = false;
35
36
 
36
37
  const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
38
+
39
+ // Regions the comparison never sees. The hub feed already skips them, through
40
+ // `require: 'autosave'`, and the input feed has to skip them for the same
41
+ // reason: their content is stripped from the comparison clone, so an edit inside
42
+ // one can never produce a dirty root, and counting it marks the page dirty with
43
+ // nothing for the oracle to find — permanently, since only a save clears the
44
+ // counter and churn in these regions triggers none.
45
+ //
46
+ // This is not a relaxation of "never under-report". A control here is absent
47
+ // from the clone by definition, so there is nothing about it to under-report.
48
+ // It matters because a mounted tool (redpen's answer field, any panel that
49
+ // marks itself no-save) is a real <textarea> in the document: one keystroke in
50
+ // it used to freeze the live-sync save baseline for the rest of the session,
51
+ // after which every incoming disk change was diffed against a stale base, and
52
+ // the previous change was spliced back over the newer one and written to disk.
53
+ const GATE_IGNORE = `${STRIP_FROM_COMPARISON}, ${SNAPSHOT_REMOVE_SELECTOR}`;
37
54
  const probeCache = new WeakMap();
38
55
 
39
56
  function onUserInput(event) {
@@ -42,9 +59,9 @@ function onUserInput(event) {
42
59
  // during a morph's async resource wait, which must keep the page dirty.
43
60
  const el = event.target;
44
61
  if (!el || el.nodeType !== 1) return;
45
- if (el.matches('input, textarea, select') || el.isContentEditable) {
46
- changes++;
47
- }
62
+ if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
63
+ if (el.closest(GATE_IGNORE)) return;
64
+ changes++;
48
65
  }
49
66
 
50
67
  export function startDirtyGate() {
@@ -21,10 +21,27 @@ export const PLUGIN_PATHS = {
21
21
  sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
22
22
  undo: { path: "plugins/undo.js", editOnly: true, default: false },
23
23
  cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
24
+ quickcrop: { path: "vendor/quickcrop.vendor.js", editOnly: false, default: false },
25
+ // editOnly, because a file picker only ever appears in edit mode: the cms
26
+ // injects its own editing toggle there and clayjs's edit-mode signal is a
27
+ // superset of the cms's, so the plugin is present exactly when it can be used.
28
+ upload: { path: "plugins/upload.js", editOnly: true, default: false },
29
+ wire: { path: "plugins/wire.js", editOnly: false, default: false },
24
30
  demo: { path: "plugins/demo.js", editOnly: false, default: false },
25
31
  };
26
32
 
27
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "demo"];
33
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo"];
34
+
35
+ // A plugin that cannot do its whole job alone. hypercms reads the cropper through
36
+ // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
37
+ // finds nothing, so `plugins=cms` has to bring quickcrop with it or image crop is
38
+ // dead with no error and no log. quickcrop loads BEFORE cms in the order above,
39
+ // because the loader attaches each plugin's member as it lands and cms reads what
40
+ // earlier plugins attached during its own evaluation.
41
+ // `upload` is deliberately NOT implied by cms yet. Adding it flips how an
42
+ // existing page behaves, from embedding an image to storing it, and that is
43
+ // isolated into its own one-line release so it can be reverted alone.
44
+ const IMPLIES = { cms: ["quickcrop"] };
28
45
 
29
46
  function parseCsv(params, key, enabled, apply) {
30
47
  const raw = params.get(key);
@@ -49,6 +66,11 @@ export function resolveModules(params, isEditMode) {
49
66
  if (spec.default) enabled.add(name);
50
67
  }
51
68
  parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
69
+ // Between the two: exclude still wins, so `plugins=cms&exclude=quickcrop` opts
70
+ // back out of the cropper.
71
+ for (const name of [...enabled]) {
72
+ for (const implied of IMPLIES[name] || []) enabled.add(implied);
73
+ }
52
74
  parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
53
75
 
54
76
  const plugins = [];
package/src/loader.js CHANGED
@@ -102,10 +102,16 @@ function attachPluginMember(path, mod) {
102
102
  clay.morph = mod.morph;
103
103
  } else if (path === "vendor/hypercms.vendor.js") {
104
104
  clay.cms = mod.cms || mod.default;
105
+ } else if (path === "plugins/upload.js") {
106
+ clay.upload = mod.upload || mod.default;
107
+ } else if (path === "plugins/wire.js") {
108
+ clay.wire = mod.wire || mod.default;
105
109
  } else if (path === "plugins/demo.js") {
106
110
  clay.demo = mod.demo;
107
111
  } else if (path === "vendor/richclay.vendor.js") {
108
112
  clay.RichClay = mod.RichClay || mod.default;
113
+ } else if (path === "vendor/quickcrop.vendor.js") {
114
+ clay.quickcrop = mod.quickcrop || mod.default;
109
115
  }
110
116
  }
111
117
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * upload.js — store a file on the host instead of embedding it (spec §9).
3
+ *
4
+ * The problem this exists for: a document that cannot upload has to put the image
5
+ * INSIDE itself as a data: URL. A two megabyte photo then costs 2.7 MB of base64
6
+ * on this save, on every future save, and in every stored version. Embedding is
7
+ * the right answer for a file nobody is serving, and the wrong answer everywhere
8
+ * else, and until now clayjs had no way to tell the difference.
9
+ *
10
+ * const result = await clay.upload(file, { onProgress, signal });
11
+ * // { ok, msg, msgType, code, uploads }
12
+ *
13
+ * The result NEVER rejects, matching the save lane. One shape from every exit,
14
+ * because two channels for three outcomes (stored, cannot store here, failed) is
15
+ * the exact pattern save-core.js documents as the source of its own past bugs: a
16
+ * "cannot store here" thrown as an error reads as a failure, and a caller that
17
+ * catches it embeds when it should have stopped, or stops when it should have
18
+ * embedded.
19
+ *
20
+ * Codes a caller branches on:
21
+ * unsupported this host does not store files; embedding is correct
22
+ * payment-required the host stores files, but not for this account
23
+ * too-large over the host's cap, refused here or by the host
24
+ * unsupported-type the host will not store this kind of file
25
+ * timeout no answer; the upload may or may not have landed
26
+ */
27
+
28
+ import { hostMeta } from "../core/host-meta.js";
29
+ import { saveToken } from "../core/host-attrs.js";
30
+
31
+ const UPLOAD_PATH = "/_/upload";
32
+ const UPLOAD_TIMEOUT_MS = 120000;
33
+
34
+ function result(ok, msg, msgType, code, uploads = []) {
35
+ return { ok, msg, msgType, code, uploads };
36
+ }
37
+
38
+ function emit(name, detail) {
39
+ if (typeof document === "undefined") return;
40
+ document.dispatchEvent(new CustomEvent(`clay:upload-${name}`, { detail }));
41
+ }
42
+
43
+ /**
44
+ * Store one file on the host.
45
+ *
46
+ * @param {File|Blob} file
47
+ * @param {Object} [options]
48
+ * @param {function} [options.onProgress] - ({loaded, total, percent})
49
+ * @param {AbortSignal} [options.signal]
50
+ * @returns {Promise<{ok: boolean, msg: string, msgType: string, code: ?string, uploads: Array}>}
51
+ */
52
+ export async function upload(file, { onProgress, signal } = {}) {
53
+ if (!file) return result(false, "No file to upload", "error", "bad-request");
54
+
55
+ const meta = await hostMeta();
56
+ if (!meta.extensions.includes("upload")) {
57
+ // Not an error. This is a document open from a plain file server, or a bare
58
+ // core host, and embedding is what it should do.
59
+ return result(false, "This host does not store uploaded files", "skipped", "unsupported");
60
+ }
61
+
62
+ // The one local pre-check the spec asks for, and the only reason `maxBytes` is
63
+ // published: refusing a 40 MB photo here costs nothing, and sending it to be
64
+ // refused costs the person the whole upload first. Deliberately the ONLY thing
65
+ // decided locally. `document.upload.allowed` is not read as a veto: it can be
66
+ // stale (a plan upgraded in another tab, a share link granted since load), and
67
+ // the host answers the real reason with its own code on the request itself.
68
+ const cap = meta.document?.upload?.maxBytes;
69
+ if (typeof cap === "number" && cap > 0 && file.size > cap) {
70
+ return result(false, `That file is larger than this host accepts (${cap} bytes)`, "error", "too-large");
71
+ }
72
+
73
+ return send(file, { onProgress, signal });
74
+ }
75
+
76
+ function send(file, { onProgress, signal }) {
77
+ return new Promise((resolve) => {
78
+ const token = saveToken();
79
+ const path = token ? `${UPLOAD_PATH}/${token}` : UPLOAD_PATH;
80
+ // Absolute against the real origin, never left relative: a <base href> in the
81
+ // authored document would otherwise redirect this request, and the
82
+ // per-document token in its path, to an origin the document chose.
83
+ const url = new URL(path, window.location.origin).href;
84
+
85
+ // XMLHttpRequest for one reason: fetch cannot report upload progress. A photo
86
+ // over a phone connection is the case this whole capability is for, and a
87
+ // picker that sits there saying nothing for twenty seconds reads as broken.
88
+ const xhr = new XMLHttpRequest();
89
+ let settled = false;
90
+
91
+ const finish = (value) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timeoutId);
95
+ if (signal) signal.removeEventListener("abort", onAbort);
96
+ resolve(value);
97
+ };
98
+
99
+ const fail = (msg, msgType, code) => {
100
+ emit("error", { msg, code });
101
+ finish(result(false, msg, msgType, code));
102
+ };
103
+
104
+ const timeoutId = setTimeout(() => {
105
+ xhr.abort();
106
+ // A timeout is not evidence the upload failed. The bytes may well have
107
+ // landed, so this says "we do not know" rather than asserting a failure,
108
+ // and the content-hash name makes the retry idempotent if the caller resends.
109
+ fail("Upload timed out", "unknown", "timeout");
110
+ }, UPLOAD_TIMEOUT_MS);
111
+
112
+ const onAbort = () => {
113
+ xhr.abort();
114
+ finish(result(false, "Upload cancelled", "skipped", "aborted"));
115
+ };
116
+ if (signal) {
117
+ if (signal.aborted) return onAbort();
118
+ signal.addEventListener("abort", onAbort);
119
+ }
120
+
121
+ xhr.upload.addEventListener("progress", (event) => {
122
+ if (!event.lengthComputable) return;
123
+ const detail = {
124
+ loaded: event.loaded,
125
+ total: event.total,
126
+ percent: Math.round((event.loaded / event.total) * 100)
127
+ };
128
+ emit("progress", detail);
129
+ if (onProgress) {
130
+ try { onProgress(detail); } catch (_) { /* a caller's drawing must not kill the upload */ }
131
+ }
132
+ });
133
+
134
+ xhr.addEventListener("load", () => {
135
+ let body = {};
136
+ try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) { body = {}; }
137
+
138
+ if (xhr.status >= 200 && xhr.status < 300) {
139
+ const uploads = Array.isArray(body.uploads) ? body.uploads : [];
140
+ if (!uploads.length || typeof uploads[0]?.url !== "string") {
141
+ return fail("The host accepted the file but did not say where it put it", "error", "bad-response");
142
+ }
143
+ emit("done", { uploads });
144
+ return finish(result(true, body.msg ?? "Uploaded", body.msgType || "success", body.code ?? null, uploads));
145
+ }
146
+
147
+ // The host's own code when it sent one, so a caller branches on the reason
148
+ // rather than pattern-matching a message. Falling back to the status keeps
149
+ // a host that answers a bare 413 readable.
150
+ const code = body.code || STATUS_CODES[xhr.status] || "error";
151
+ return fail(body.msg || `Upload failed (${xhr.status})`, "error", code);
152
+ });
153
+
154
+ xhr.addEventListener("error", () => fail("Upload failed", "error", "network"));
155
+ xhr.addEventListener("abort", () => finish(result(false, "Upload cancelled", "skipped", "aborted")));
156
+
157
+ xhr.open("POST", url, true);
158
+ // The exact equivalent of the save lane's `credentials: 'omit'` in the case
159
+ // where the two differ: a sandboxed document is cross-origin to its own host,
160
+ // and asking for credentials there needs Access-Control-Allow-Credentials
161
+ // back, which a token-minting host must never send.
162
+ xhr.withCredentials = false;
163
+ xhr.setRequestHeader("Document-URL", window.location.href);
164
+
165
+ // FormData, so the file streams as bytes. No FileReader and no base64: a
166
+ // whole-file string costs a third more bytes and holds the entire file in
167
+ // memory twice.
168
+ const form = new FormData();
169
+ form.append("file", file, file.name || "upload");
170
+
171
+ emit("start", { name: file.name || null, size: file.size ?? null });
172
+ xhr.send(form);
173
+ });
174
+ }
175
+
176
+ const STATUS_CODES = {
177
+ 402: "payment-required",
178
+ 413: "too-large",
179
+ 415: "unsupported-type",
180
+ 401: "unauthorized",
181
+ 403: "forbidden",
182
+ 404: "not-found"
183
+ };
184
+
185
+ export default upload;