makersclaw 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/r/activity-log.json +3 -3
- package/r/agent-changes.json +3 -3
- package/r/agent-chat.json +3 -3
- package/r/agent-column.json +3 -3
- package/r/agent-dock.json +3 -3
- package/r/agent-kit.json +2 -2
- package/r/agent-marks.json +2 -2
- package/r/agent-pane.json +4 -4
- package/r/ai-action.json +4 -4
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/approval.json +3 -3
- package/r/ask-bar.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +7 -7
- package/r/automation-list.json +3 -3
- package/r/automation-model.json +2 -2
- package/r/automations-screen.json +7 -7
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat-pane.json +2 -2
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/connect-card.json +2 -2
- package/r/contact-section.json +2 -2
- package/r/cost.json +2 -2
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +3 -3
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/first-run.json +2 -2
- package/r/fix-note.json +4 -4
- package/r/flow-canvas.json +2 -2
- package/r/flow-editor.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/library-shelf.json +2 -2
- package/r/map.json +2 -2
- package/r/margin-notes.json +3 -3
- package/r/menubar.json +2 -2
- package/r/mind-map.json +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/needs-you.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/pace-bar.json +3 -3
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +2 -2
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-grid.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +290 -249
- package/r/run-history.json +2 -2
- package/r/run-view.json +6 -6
- package/r/schedule-card.json +2 -2
- package/r/schedule-engine.json +2 -2
- package/r/schedule-gantt.json +3 -3
- package/r/schedule-ruler.json +2 -2
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/source-receipt.json +3 -3
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +3 -3
- package/r/step-settings.json +5 -5
- package/r/suggestion.json +2 -2
- package/r/sum-bar.json +2 -2
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/tier-map.json +2 -2
- package/r/time-grid.json +2 -2
- package/r/time-strip.json +2 -2
- package/r/top-bar.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +3 -3
- package/r/whiteboard.json +3 -3
- package/r/workflow-view.json +3 -3
- package/r/workspace-objects.json +45 -0
- package/registry/blocks/workspace-objects/README.md +23 -0
- package/registry/blocks/workspace-objects/block.json +10 -0
- package/registry/blocks/workspace-objects/demo.jsx +3 -0
- package/registry/blocks/workspace-objects/index.js +1 -0
- package/registry/blocks/workspace-objects/objects.js +147 -0
package/r/transaction-list.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "TransactionList",
|
|
6
6
|
"description": "Money records as a plain list, in two variants: history (payments in and out, grouped by month, a received / paid / pending / failed mark and a signed amount) and invoices (number, date, a Paid / Due in N days / N days overdue badge, amount, download). Optional date-range filter, Export and per-row download. Empty, loading and error states built in.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"docs": "Installed to components/makersclaw/blocks/transaction-list. Import: import { TransactionList } from \"@/components/makersclaw/blocks/transaction-list\". Props and an example: components/makersclaw/blocks/transaction-list/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
47
47
|
"meta": {
|
|
48
48
|
"makersclaw": {
|
|
49
|
-
"version": "0.8.
|
|
49
|
+
"version": "0.8.5",
|
|
50
50
|
"useWhen": "A billing page's invoices, an account's payment history, a wallet's recent activity: records read top to bottom, newest first. Not for records the founder sorts, filters by column or totals (data-table), or for one order (order-detail)."
|
|
51
51
|
}
|
|
52
52
|
}
|
package/r/trigger-picker.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "TriggerPicker",
|
|
6
6
|
"description": "What starts an automation, as a panel: by hand, on a schedule in the team's time zone, when an app sends an event, when one of an app's actions finishes, when a record is added or changed, when the automation's own web address is called (its secret made and replaced here, shown once), or when another automation finishes. The platform says which of these it runs (the list door's triggers); a connection's own events are listed as \"soon\" with what to use meanwhile. The schedule is offered in the shapes people ask for (every day, every weekday, every week, every hour, another schedule as five-field cron) with a time of day, a weekday, the team's zone or another, and a sentence saying it back (\"Runs weekdays at 07:00, team time\").",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/automation-model.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"docs": "Installed to components/makersclaw/blocks/trigger-picker. Import: import { TriggerPicker } from \"@/components/makersclaw/blocks/trigger-picker\". Props and an example: components/makersclaw/blocks/trigger-picker/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
55
55
|
"meta": {
|
|
56
56
|
"makersclaw": {
|
|
57
|
-
"version": "0.8.
|
|
57
|
+
"version": "0.8.5",
|
|
58
58
|
"useWhen": "An automation's trigger is edited in your own screen. automation-editor opens it when the trigger card is pressed; pass it the platform's triggers (useAutomations().triggers) so it offers what the platform runs."
|
|
59
59
|
}
|
|
60
60
|
}
|
package/r/whiteboard.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "Whiteboard",
|
|
6
6
|
"description": "A full-page freeform whiteboard on sketch-canvas: a top bar with the board's title (click to rename), the save state (Saving…, Saved, or the error with Retry), a Board / Text view switch, Export PNG and an optional Share button; under it the Miro-style canvas (tool rail, floating toolbar, zoom, undo). An empty board offers starter templates — blank, a grid of titled areas, a 2×2 matrix — and the Text view lists every word on the board for a screen reader or an agent. With onCreateTask, a selected note or text becomes a task from the top bar (\"Create task\", editable title; several notes make one task each), and a small badge marks it on the board from then on. The sketch is plain JSON; saves are async callbacks answering { ok }.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/sketch-canvas.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"docs": "Installed to components/makersclaw/blocks/whiteboard. Import: import { Whiteboard, WHITEBOARD_TEMPLATES, gridSketch, matrixSketch } from \"@/components/makersclaw/blocks/whiteboard\". Props and an example: components/makersclaw/blocks/whiteboard/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
60
60
|
"meta": {
|
|
61
61
|
"makersclaw": {
|
|
62
|
-
"version": "0.8.
|
|
62
|
+
"version": "0.8.5",
|
|
63
63
|
"useWhen": "A screen is one board to think on: a team whiteboard, a planning or prioritisation board, a place to sketch an idea and keep it with a record. Not for marking up a screenshot (annotate), sketching a UI to build (sketch-to-app), a structured board of cards and connectors (flow-canvas), or a kanban (board)."
|
|
64
64
|
}
|
|
65
65
|
}
|
package/r/workflow-view.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "WorkflowView",
|
|
6
6
|
"description": "One job of the app's agent drawn as a graph on a dotted ground: the Trigger (\"Every weekday at 07:00\", \"When a deal is created\") → each step of the chosen run with how it went, as a mark and a word → the result. Over it a run picker with Re-run; in its head the Live switch and \"Trigger manually\" with its price; beside it the run history and the overview (run-history). Before any run it draws the job's declared steps in pencil, with an honest empty line. Fed by useJobs, useRuns and useRun (lib/hooks.js). Under about 768 px of its own width the history stacks under the graph and the graph runs top to bottom.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/run-history.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"docs": "Installed to components/makersclaw/blocks/workflow-view. Import: import { WorkflowView } from \"@/components/makersclaw/blocks/workflow-view\". Props and an example: components/makersclaw/blocks/workflow-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
49
|
"meta": {
|
|
50
50
|
"makersclaw": {
|
|
51
|
-
"version": "0.8.
|
|
51
|
+
"version": "0.8.5",
|
|
52
52
|
"useWhen": "Superseded by automation-editor and run-view for an app whose jobs run on the automations engine; still the block for an app that has not moved (its jobs are the agent's wakes). A job's own page: the founder opens an automation to see what it does, how its last runs went and to run it now — a morning read, an inbox sweep, a nightly enrichment, an event-triggered workflow. For the job as a card beside the day's work use schedule-card; for a plain list of runs use run-history."
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "workspace-objects",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Workspace objects",
|
|
6
|
+
"description": "Reusable bounded file hashing, resumable workspace uploads and local file references. The host supplies authorized transport and storage.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/blocks/workspace-objects/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/workspace-objects/index.js",
|
|
16
|
+
"content": "export { createObjectClient, createLocalObjects, fingerprintFile, ObjectTransferError } from \"./objects.js\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/workspace-objects/objects.js",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/workspace-objects/objects.js",
|
|
22
|
+
"content": "// Pure transfer orchestration. All network and persistence authority belongs\n// to the injected host. Only one bounded file slice is read at a time.\nconst DEFAULT_PART = 8 * 1024 * 1024;\nconst MAX_PART = 64 * 1024 * 1024;\nconst hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, \"0\")).join(\"\");\nconst stopped = (signal) => { if (signal?.aborted) throw new DOMException(\"Transfer cancelled\", \"AbortError\"); };\nconst hash = async (bytes) => hex(new Uint8Array(await globalThis.crypto.subtle.digest(\"SHA-256\", bytes)));\n\nexport class ObjectTransferError extends Error {\n constructor(code, message) { super(message); this.name = \"ObjectTransferError\"; this.code = code; }\n}\n\nfunction fileCheck(file) {\n if (!file || typeof file.slice !== \"function\" || !Number.isSafeInteger(file.size) || file.size <= 0)\n throw new ObjectTransferError(\"invalid_file\", \"Choose a nonempty file.\");\n}\nfunction partCheck(size) {\n if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PART)\n throw new ObjectTransferError(\"invalid_part_size\", \"The upload service returned an unsupported part size.\");\n}\nasync function partHash(file, start, size, signal) {\n stopped(signal);\n const bytes = await file.slice(start, Math.min(file.size, start + size)).arrayBuffer();\n stopped(signal);\n if (bytes.byteLength !== Math.min(size, file.size - start)) throw new ObjectTransferError(\"source_read_failed\", \"The original file could not be read completely.\");\n return hash(bytes);\n}\n\n/** A content identity from every bounded slice, not just name/size/mtime.\n * It is a manifest fingerprint, deliberately not called a whole-file SHA256. */\nexport async function fingerprintFile(file, { partSize = DEFAULT_PART, signal, onProgress } = {}) {\n fileCheck(file); partCheck(partSize);\n const count = Math.ceil(file.size / partSize);\n // The object service's 10 GiB target has 1280 parts. Bound hostile inputs\n // before allocating the small hash manifest; never allocate file.size.\n if (count > 16384) throw new ObjectTransferError(\"too_many_parts\", \"This file needs more parts than the transfer client supports.\");\n const hashes = [];\n for (let index = 0; index < count; index++) {\n hashes.push(await partHash(file, index * partSize, partSize, signal));\n onProgress?.({ stage: \"hashing\", loaded: Math.min(file.size, (index + 1) * partSize), total: file.size });\n }\n stopped(signal);\n const identity = await hash(new TextEncoder().encode(JSON.stringify({ size: file.size, partSize, hashes })));\n return { identity, partSize, hashes };\n}\n\n/** request paths are relative to the host object API. uploadPart is the host's\n * direct-to-storage upload, never a large request to the app data door. */\nexport function createObjectClient({ request, uploadPart, storage, scope = \"default\", partSize = DEFAULT_PART }) {\n if (typeof request !== \"function\" || typeof uploadPart !== \"function\") throw new TypeError(\"Authorized request and uploadPart callbacks are required\");\n const prefix = `workspace-objects:v1:${scope}:`;\n const load = (id) => { try { return JSON.parse(storage?.getItem(prefix + id) || \"null\"); } catch { return null; } };\n const keep = (id, value) => { try { storage?.setItem(prefix + id, JSON.stringify(value)); return Boolean(storage); } catch { return false; } };\n const forget = (id) => { try { storage?.removeItem(prefix + id); } catch { /* transfer success does not depend on local storage */ } };\n const call = (path, body, signal) => request(path, { method: body === undefined ? \"GET\" : \"POST\", ...(body === undefined ? {} : { body }), signal });\n\n return {\n getObject: (id, opts = {}) => call(`/${encodeURIComponent(id)}`, undefined, opts.signal),\n getObjectUrl: (id, opts = {}) => call(`/${encodeURIComponent(id)}/url`, undefined, opts.signal),\n listObjects: (opts = {}) => call(\"/\", undefined, opts.signal),\n trashObject: (id, opts = {}) => request(`/${encodeURIComponent(id)}`, { method: \"DELETE\", signal: opts.signal }),\n resumeState: load,\n async uploadObject(file, { resumeId, signal, onProgress } = {}) {\n fileCheck(file); stopped(signal);\n const known = resumeId ? load(resumeId) : null;\n if (resumeId && !known) throw new ObjectTransferError(\"resume_identity_missing\", \"Reselect the original file in the browser that started this upload, or start a new upload.\");\n const fingerprint = await fingerprintFile(file, { partSize: known?.partSize || partSize, signal, onProgress });\n if (known && (known.identity !== fingerprint.identity || known.size !== file.size))\n throw new ObjectTransferError(\"resume_file_changed\", \"This is a different file. Choose the original file to resume, or start a new upload.\");\n // Finalization may have succeeded just before its response was lost.\n if (known) {\n const previous = await call(`/${encodeURIComponent(known.objectId)}`, undefined, signal);\n if (previous.object?.status === \"ready\") {\n if (previous.object.id !== known.objectId || previous.object.size !== file.size) throw new ObjectTransferError(\"invalid_upload\", \"The completed file does not match this upload.\");\n forget(resumeId);\n onProgress?.({ stage: \"ready\", loaded: file.size, total: file.size, objectId: known.objectId });\n return previous.object;\n }\n }\n const begun = await call(\"/begin\", { name: file.name || \"Untitled file\", mime: file.type || \"application/octet-stream\", size: file.size, fingerprint: fingerprint.identity, ...(resumeId ? { resume_id: resumeId } : {}) }, signal);\n const object = begun.object;\n const upload = begun.upload;\n if (!object?.id || !upload?.id) throw new ObjectTransferError(\"invalid_upload\", \"The upload service did not return an upload identity.\");\n partCheck(upload.part_size);\n if (upload.parts !== Math.ceil(file.size / upload.part_size)) throw new ObjectTransferError(\"invalid_upload\", \"The upload service returned an inconsistent part count.\");\n if (upload.part_size !== fingerprint.partSize) throw new ObjectTransferError(\"invalid_upload\", \"The upload service changed the fingerprint part size. Start a new upload with the supported part size.\");\n const current = fingerprint;\n const resume = { id: upload.id, objectId: object.id, name: file.name || \"Untitled file\", size: file.size, partSize: upload.part_size, identity: current.identity };\n const resumable = keep(upload.id, resume);\n const complete = new Set(upload.completed_parts || []);\n if ([...complete].some((i) => !Number.isInteger(i) || i < 0 || i >= upload.parts)) throw new ObjectTransferError(\"invalid_upload\", \"The upload service returned invalid completed parts.\");\n let uploaded = [...complete].reduce((sum, i) => sum + Math.min(upload.part_size, file.size - i * upload.part_size), 0);\n onProgress?.({ stage: \"uploading\", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });\n for (let index = 0; index < upload.parts; index++) {\n stopped(signal);\n if (complete.has(index)) continue;\n const start = index * upload.part_size;\n const blob = file.slice(start, Math.min(file.size, start + upload.part_size));\n // Detect a mutable file changing after the initial identity pass.\n const actual = await partHash(file, start, upload.part_size, signal);\n if (actual !== current.hashes[index]) throw new ObjectTransferError(\"source_changed\", \"The original file changed during upload. Start a new upload from the finished file.\");\n // A previous PUT may have succeeded just before the browser lost its\n // response. Reconcile it before trying another immutable upload.\n {\n try {\n await call(`/${encodeURIComponent(object.id)}/parts/${index}/complete`, { upload_id: upload.id, sha256: actual }, signal);\n uploaded += blob.size;\n onProgress?.({ stage: \"uploading\", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });\n continue;\n } catch (error) {\n if (error?.status !== 404 && ![\"part_missing\", \"object_part_missing\"].includes(error?.code)) throw error;\n }\n }\n await uploadPart({ object, upload, index, blob, sha256: actual, signal, onProgress: (fraction) => onProgress?.({ stage: \"uploading\", loaded: uploaded + Math.round(blob.size * Math.max(0, Math.min(1, Number(fraction) || 0))), total: file.size, objectId: object.id, resumeId: upload.id, resumable }) });\n stopped(signal);\n await call(`/${encodeURIComponent(object.id)}/parts/${index}/complete`, { upload_id: upload.id, sha256: actual }, signal);\n uploaded += blob.size;\n onProgress?.({ stage: \"uploading\", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });\n }\n stopped(signal);\n const result = await call(`/${encodeURIComponent(object.id)}/complete`, { upload_id: upload.id }, signal);\n if (result.object?.status !== \"ready\") throw new ObjectTransferError(\"not_ready\", \"The file is still being verified. Its upload can be resumed.\");\n forget(upload.id);\n onProgress?.({ stage: \"ready\", loaded: file.size, total: file.size, objectId: object.id });\n return result.object;\n },\n };\n}\n\n/** Local File references survive editor changes, but not a browser restart.\n * Reopen/relink checks the complete bounded-slice fingerprint first. */\nexport function createLocalObjects({ createUrl = (file) => URL.createObjectURL(file), revokeUrl = (url) => URL.revokeObjectURL(url) } = {}) {\n const entries = new Map();\n return {\n async register(file, options = {}) {\n const fp = await fingerprintFile(file, options);\n const id = `local:${fp.identity}`;\n if (options.expectedId && options.expectedId !== id) throw new ObjectTransferError(\"local_file_changed\", \"Choose the original local file to relink this footage.\");\n if (!entries.has(id)) entries.set(id, { file, url: createUrl(file), object: { id, name: file.name || \"Local file\", mime: file.type || \"application/octet-stream\", size: file.size, status: \"local\", version: 1, identity: fp.identity } });\n return entries.get(id).object;\n },\n url(id) { const entry = entries.get(id); if (!entry) throw new ObjectTransferError(\"local_file_missing\", \"This footage is local to another browser session. Relink the original file or upload it to the workspace.\"); return { url: entry.url, local: true }; },\n file(id) { return entries.get(id)?.file || null; },\n release(id) { const entry = entries.get(id); if (entry) revokeUrl(entry.url); entries.delete(id); },\n dispose() { for (const entry of entries.values()) revokeUrl(entry.url); entries.clear(); },\n };\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/workspace-objects/README.md",
|
|
26
|
+
"type": "registry:file",
|
|
27
|
+
"target": "@components/makersclaw/blocks/workspace-objects/README.md",
|
|
28
|
+
"content": "# Workspace objects\n\nA portable client for the platform object engine. The host injects authorized requests, direct storage uploads and optional resume-state storage. The block does not know credentials, workspace identity, app paths or billing.\n\n## Props\n\n`createObjectClient({request,uploadPart,storage,scope,partSize?})` returns `uploadObject`, `getObject`, `getObjectUrl`, `listObjects`, `trashObject` and `resumeState`.\n\n- `request(path,{method,body,signal})`: relative to the app's authorized `/_claw/objects`; resolves parsed JSON or throws an error preserving status/code. Begin binds the full content fingerprint; its part size must match the negotiated part size (8MiB default).\n- `uploadPart({object,upload,index,blob,sha256,signal,onProgress})`: upload this bounded Blob directly to its private storage grant. `onProgress` is fraction0–1. Never send binary through the app data door.\n- `storage`: optional localStorage-shaped getItem/setItem/removeItem, namespaced by `scope` (workspace and app identity). Contains identifiers and content fingerprints, never file bytes or credentials.\n- `uploadObject(file,{resumeId,signal,onProgress})` reports `{stage,loaded,total,objectId?,resumeId?,resumable?}`. Hashing and uploading are distinct stages. Returns a ready platform object only after final verification. Hashing scans every8MiB slice sequentially, including on resume; no giant ArrayBuffer or base64 payload. Fingerprints are hash manifests, not whole-file hashes.\n- `createLocalObjects()` holds browser File references. `register(file,{expectedId?,signal,onProgress})` returns a local object; `url(id)` requires the live file, `file(id)` enables optional upload, `release`/`dispose` revoke URLs. Local objects must be labelled as requiring relink on another device/restart. They are not a workspace backup.\n\n## Example\n\n```js\nconst objects = createObjectClient({ request: authorizedObjectRequest, uploadPart: directPartUpload, storage: localStorage, scope: `${workspaceId}:${appId}` });\nconst object = await objects.uploadObject(file, { onProgress: setProgress, signal });\nconst { url } = await objects.getObjectUrl(object.id);\n```\n\nThe host refreshes expiring read URLs while retaining playback position. Background media jobs take ready workspace object IDs; local footage must first be uploaded. Preserve original references when attaching proxy outputs. Quotas are reported by the server, not invented by this client.\n"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"categories": [
|
|
32
|
+
"files",
|
|
33
|
+
"objects",
|
|
34
|
+
"upload",
|
|
35
|
+
"streaming",
|
|
36
|
+
"media"
|
|
37
|
+
],
|
|
38
|
+
"docs": "Installed to components/makersclaw/blocks/workspace-objects. Import: import { createObjectClient, createLocalObjects, fingerprintFile, ObjectTransferError } from \"@/components/makersclaw/blocks/workspace-objects\". Props and an example: components/makersclaw/blocks/workspace-objects/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
39
|
+
"meta": {
|
|
40
|
+
"makersclaw": {
|
|
41
|
+
"version": "0.8.5",
|
|
42
|
+
"useWhen": "Working with large files without loading the complete file into memory or embedding bytes in database rows."
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Workspace objects
|
|
2
|
+
|
|
3
|
+
A portable client for the platform object engine. The host injects authorized requests, direct storage uploads and optional resume-state storage. The block does not know credentials, workspace identity, app paths or billing.
|
|
4
|
+
|
|
5
|
+
## Props
|
|
6
|
+
|
|
7
|
+
`createObjectClient({request,uploadPart,storage,scope,partSize?})` returns `uploadObject`, `getObject`, `getObjectUrl`, `listObjects`, `trashObject` and `resumeState`.
|
|
8
|
+
|
|
9
|
+
- `request(path,{method,body,signal})`: relative to the app's authorized `/_claw/objects`; resolves parsed JSON or throws an error preserving status/code. Begin binds the full content fingerprint; its part size must match the negotiated part size (8MiB default).
|
|
10
|
+
- `uploadPart({object,upload,index,blob,sha256,signal,onProgress})`: upload this bounded Blob directly to its private storage grant. `onProgress` is fraction0–1. Never send binary through the app data door.
|
|
11
|
+
- `storage`: optional localStorage-shaped getItem/setItem/removeItem, namespaced by `scope` (workspace and app identity). Contains identifiers and content fingerprints, never file bytes or credentials.
|
|
12
|
+
- `uploadObject(file,{resumeId,signal,onProgress})` reports `{stage,loaded,total,objectId?,resumeId?,resumable?}`. Hashing and uploading are distinct stages. Returns a ready platform object only after final verification. Hashing scans every8MiB slice sequentially, including on resume; no giant ArrayBuffer or base64 payload. Fingerprints are hash manifests, not whole-file hashes.
|
|
13
|
+
- `createLocalObjects()` holds browser File references. `register(file,{expectedId?,signal,onProgress})` returns a local object; `url(id)` requires the live file, `file(id)` enables optional upload, `release`/`dispose` revoke URLs. Local objects must be labelled as requiring relink on another device/restart. They are not a workspace backup.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const objects = createObjectClient({ request: authorizedObjectRequest, uploadPart: directPartUpload, storage: localStorage, scope: `${workspaceId}:${appId}` });
|
|
19
|
+
const object = await objects.uploadObject(file, { onProgress: setProgress, signal });
|
|
20
|
+
const { url } = await objects.getObjectUrl(object.id);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The host refreshes expiring read URLs while retaining playback position. Background media jobs take ready workspace object IDs; local footage must first be uploaded. Preserve original references when attaching proxy outputs. Quotas are reported by the server, not invented by this client.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workspace-objects",
|
|
3
|
+
"title": "Workspace objects",
|
|
4
|
+
"description": "Reusable bounded file hashing, resumable workspace uploads and local file references. The host supplies authorized transport and storage.",
|
|
5
|
+
"useWhen": "Working with large files without loading the complete file into memory or embedding bytes in database rows.",
|
|
6
|
+
"tags": ["files", "objects", "upload", "streaming", "media"],
|
|
7
|
+
"exports": ["createObjectClient", "createLocalObjects", "fingerprintFile", "ObjectTransferError"],
|
|
8
|
+
"files": ["index.js", "objects.js"],
|
|
9
|
+
"requires": []
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createObjectClient, createLocalObjects, fingerprintFile, ObjectTransferError } from "./objects.js";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Pure transfer orchestration. All network and persistence authority belongs
|
|
2
|
+
// to the injected host. Only one bounded file slice is read at a time.
|
|
3
|
+
const DEFAULT_PART = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_PART = 64 * 1024 * 1024;
|
|
5
|
+
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
const stopped = (signal) => { if (signal?.aborted) throw new DOMException("Transfer cancelled", "AbortError"); };
|
|
7
|
+
const hash = async (bytes) => hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes)));
|
|
8
|
+
|
|
9
|
+
export class ObjectTransferError extends Error {
|
|
10
|
+
constructor(code, message) { super(message); this.name = "ObjectTransferError"; this.code = code; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fileCheck(file) {
|
|
14
|
+
if (!file || typeof file.slice !== "function" || !Number.isSafeInteger(file.size) || file.size <= 0)
|
|
15
|
+
throw new ObjectTransferError("invalid_file", "Choose a nonempty file.");
|
|
16
|
+
}
|
|
17
|
+
function partCheck(size) {
|
|
18
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PART)
|
|
19
|
+
throw new ObjectTransferError("invalid_part_size", "The upload service returned an unsupported part size.");
|
|
20
|
+
}
|
|
21
|
+
async function partHash(file, start, size, signal) {
|
|
22
|
+
stopped(signal);
|
|
23
|
+
const bytes = await file.slice(start, Math.min(file.size, start + size)).arrayBuffer();
|
|
24
|
+
stopped(signal);
|
|
25
|
+
if (bytes.byteLength !== Math.min(size, file.size - start)) throw new ObjectTransferError("source_read_failed", "The original file could not be read completely.");
|
|
26
|
+
return hash(bytes);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A content identity from every bounded slice, not just name/size/mtime.
|
|
30
|
+
* It is a manifest fingerprint, deliberately not called a whole-file SHA256. */
|
|
31
|
+
export async function fingerprintFile(file, { partSize = DEFAULT_PART, signal, onProgress } = {}) {
|
|
32
|
+
fileCheck(file); partCheck(partSize);
|
|
33
|
+
const count = Math.ceil(file.size / partSize);
|
|
34
|
+
// The object service's 10 GiB target has 1280 parts. Bound hostile inputs
|
|
35
|
+
// before allocating the small hash manifest; never allocate file.size.
|
|
36
|
+
if (count > 16384) throw new ObjectTransferError("too_many_parts", "This file needs more parts than the transfer client supports.");
|
|
37
|
+
const hashes = [];
|
|
38
|
+
for (let index = 0; index < count; index++) {
|
|
39
|
+
hashes.push(await partHash(file, index * partSize, partSize, signal));
|
|
40
|
+
onProgress?.({ stage: "hashing", loaded: Math.min(file.size, (index + 1) * partSize), total: file.size });
|
|
41
|
+
}
|
|
42
|
+
stopped(signal);
|
|
43
|
+
const identity = await hash(new TextEncoder().encode(JSON.stringify({ size: file.size, partSize, hashes })));
|
|
44
|
+
return { identity, partSize, hashes };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** request paths are relative to the host object API. uploadPart is the host's
|
|
48
|
+
* direct-to-storage upload, never a large request to the app data door. */
|
|
49
|
+
export function createObjectClient({ request, uploadPart, storage, scope = "default", partSize = DEFAULT_PART }) {
|
|
50
|
+
if (typeof request !== "function" || typeof uploadPart !== "function") throw new TypeError("Authorized request and uploadPart callbacks are required");
|
|
51
|
+
const prefix = `workspace-objects:v1:${scope}:`;
|
|
52
|
+
const load = (id) => { try { return JSON.parse(storage?.getItem(prefix + id) || "null"); } catch { return null; } };
|
|
53
|
+
const keep = (id, value) => { try { storage?.setItem(prefix + id, JSON.stringify(value)); return Boolean(storage); } catch { return false; } };
|
|
54
|
+
const forget = (id) => { try { storage?.removeItem(prefix + id); } catch { /* transfer success does not depend on local storage */ } };
|
|
55
|
+
const call = (path, body, signal) => request(path, { method: body === undefined ? "GET" : "POST", ...(body === undefined ? {} : { body }), signal });
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
getObject: (id, opts = {}) => call(`/${encodeURIComponent(id)}`, undefined, opts.signal),
|
|
59
|
+
getObjectUrl: (id, opts = {}) => call(`/${encodeURIComponent(id)}/url`, undefined, opts.signal),
|
|
60
|
+
listObjects: (opts = {}) => call("/", undefined, opts.signal),
|
|
61
|
+
trashObject: (id, opts = {}) => request(`/${encodeURIComponent(id)}`, { method: "DELETE", signal: opts.signal }),
|
|
62
|
+
resumeState: load,
|
|
63
|
+
async uploadObject(file, { resumeId, signal, onProgress } = {}) {
|
|
64
|
+
fileCheck(file); stopped(signal);
|
|
65
|
+
const known = resumeId ? load(resumeId) : null;
|
|
66
|
+
if (resumeId && !known) throw new ObjectTransferError("resume_identity_missing", "Reselect the original file in the browser that started this upload, or start a new upload.");
|
|
67
|
+
const fingerprint = await fingerprintFile(file, { partSize: known?.partSize || partSize, signal, onProgress });
|
|
68
|
+
if (known && (known.identity !== fingerprint.identity || known.size !== file.size))
|
|
69
|
+
throw new ObjectTransferError("resume_file_changed", "This is a different file. Choose the original file to resume, or start a new upload.");
|
|
70
|
+
// Finalization may have succeeded just before its response was lost.
|
|
71
|
+
if (known) {
|
|
72
|
+
const previous = await call(`/${encodeURIComponent(known.objectId)}`, undefined, signal);
|
|
73
|
+
if (previous.object?.status === "ready") {
|
|
74
|
+
if (previous.object.id !== known.objectId || previous.object.size !== file.size) throw new ObjectTransferError("invalid_upload", "The completed file does not match this upload.");
|
|
75
|
+
forget(resumeId);
|
|
76
|
+
onProgress?.({ stage: "ready", loaded: file.size, total: file.size, objectId: known.objectId });
|
|
77
|
+
return previous.object;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const begun = await call("/begin", { name: file.name || "Untitled file", mime: file.type || "application/octet-stream", size: file.size, fingerprint: fingerprint.identity, ...(resumeId ? { resume_id: resumeId } : {}) }, signal);
|
|
81
|
+
const object = begun.object;
|
|
82
|
+
const upload = begun.upload;
|
|
83
|
+
if (!object?.id || !upload?.id) throw new ObjectTransferError("invalid_upload", "The upload service did not return an upload identity.");
|
|
84
|
+
partCheck(upload.part_size);
|
|
85
|
+
if (upload.parts !== Math.ceil(file.size / upload.part_size)) throw new ObjectTransferError("invalid_upload", "The upload service returned an inconsistent part count.");
|
|
86
|
+
if (upload.part_size !== fingerprint.partSize) throw new ObjectTransferError("invalid_upload", "The upload service changed the fingerprint part size. Start a new upload with the supported part size.");
|
|
87
|
+
const current = fingerprint;
|
|
88
|
+
const resume = { id: upload.id, objectId: object.id, name: file.name || "Untitled file", size: file.size, partSize: upload.part_size, identity: current.identity };
|
|
89
|
+
const resumable = keep(upload.id, resume);
|
|
90
|
+
const complete = new Set(upload.completed_parts || []);
|
|
91
|
+
if ([...complete].some((i) => !Number.isInteger(i) || i < 0 || i >= upload.parts)) throw new ObjectTransferError("invalid_upload", "The upload service returned invalid completed parts.");
|
|
92
|
+
let uploaded = [...complete].reduce((sum, i) => sum + Math.min(upload.part_size, file.size - i * upload.part_size), 0);
|
|
93
|
+
onProgress?.({ stage: "uploading", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });
|
|
94
|
+
for (let index = 0; index < upload.parts; index++) {
|
|
95
|
+
stopped(signal);
|
|
96
|
+
if (complete.has(index)) continue;
|
|
97
|
+
const start = index * upload.part_size;
|
|
98
|
+
const blob = file.slice(start, Math.min(file.size, start + upload.part_size));
|
|
99
|
+
// Detect a mutable file changing after the initial identity pass.
|
|
100
|
+
const actual = await partHash(file, start, upload.part_size, signal);
|
|
101
|
+
if (actual !== current.hashes[index]) throw new ObjectTransferError("source_changed", "The original file changed during upload. Start a new upload from the finished file.");
|
|
102
|
+
// A previous PUT may have succeeded just before the browser lost its
|
|
103
|
+
// response. Reconcile it before trying another immutable upload.
|
|
104
|
+
{
|
|
105
|
+
try {
|
|
106
|
+
await call(`/${encodeURIComponent(object.id)}/parts/${index}/complete`, { upload_id: upload.id, sha256: actual }, signal);
|
|
107
|
+
uploaded += blob.size;
|
|
108
|
+
onProgress?.({ stage: "uploading", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });
|
|
109
|
+
continue;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.status !== 404 && !["part_missing", "object_part_missing"].includes(error?.code)) throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await uploadPart({ object, upload, index, blob, sha256: actual, signal, onProgress: (fraction) => onProgress?.({ stage: "uploading", loaded: uploaded + Math.round(blob.size * Math.max(0, Math.min(1, Number(fraction) || 0))), total: file.size, objectId: object.id, resumeId: upload.id, resumable }) });
|
|
115
|
+
stopped(signal);
|
|
116
|
+
await call(`/${encodeURIComponent(object.id)}/parts/${index}/complete`, { upload_id: upload.id, sha256: actual }, signal);
|
|
117
|
+
uploaded += blob.size;
|
|
118
|
+
onProgress?.({ stage: "uploading", loaded: uploaded, total: file.size, objectId: object.id, resumeId: upload.id, resumable });
|
|
119
|
+
}
|
|
120
|
+
stopped(signal);
|
|
121
|
+
const result = await call(`/${encodeURIComponent(object.id)}/complete`, { upload_id: upload.id }, signal);
|
|
122
|
+
if (result.object?.status !== "ready") throw new ObjectTransferError("not_ready", "The file is still being verified. Its upload can be resumed.");
|
|
123
|
+
forget(upload.id);
|
|
124
|
+
onProgress?.({ stage: "ready", loaded: file.size, total: file.size, objectId: object.id });
|
|
125
|
+
return result.object;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Local File references survive editor changes, but not a browser restart.
|
|
131
|
+
* Reopen/relink checks the complete bounded-slice fingerprint first. */
|
|
132
|
+
export function createLocalObjects({ createUrl = (file) => URL.createObjectURL(file), revokeUrl = (url) => URL.revokeObjectURL(url) } = {}) {
|
|
133
|
+
const entries = new Map();
|
|
134
|
+
return {
|
|
135
|
+
async register(file, options = {}) {
|
|
136
|
+
const fp = await fingerprintFile(file, options);
|
|
137
|
+
const id = `local:${fp.identity}`;
|
|
138
|
+
if (options.expectedId && options.expectedId !== id) throw new ObjectTransferError("local_file_changed", "Choose the original local file to relink this footage.");
|
|
139
|
+
if (!entries.has(id)) entries.set(id, { file, url: createUrl(file), object: { id, name: file.name || "Local file", mime: file.type || "application/octet-stream", size: file.size, status: "local", version: 1, identity: fp.identity } });
|
|
140
|
+
return entries.get(id).object;
|
|
141
|
+
},
|
|
142
|
+
url(id) { const entry = entries.get(id); if (!entry) throw new ObjectTransferError("local_file_missing", "This footage is local to another browser session. Relink the original file or upload it to the workspace."); return { url: entry.url, local: true }; },
|
|
143
|
+
file(id) { return entries.get(id)?.file || null; },
|
|
144
|
+
release(id) { const entry = entries.get(id); if (entry) revokeUrl(entry.url); entries.delete(id); },
|
|
145
|
+
dispose() { for (const entry of entries.values()) revokeUrl(entry.url); entries.clear(); },
|
|
146
|
+
};
|
|
147
|
+
}
|