@agent-native/core 0.114.10 → 0.114.12
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +89 -1
- package/corpus/core/src/extensions/actions.ts +219 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +48 -0
- package/corpus/templates/clips/app/lib/fmp4.ts +26 -4
- package/corpus/templates/clips/app/lib/mse-video-loader.ts +13 -2
- package/corpus/templates/clips/changelog/2026-07-20-rewind-capture-status.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +81 -71
- package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +103 -0
- package/corpus/templates/clips/desktop/src/styles.css +10 -0
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +65 -1
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +193 -1
- package/dist/extensions/actions.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/client/extensions/EmbeddedExtension.tsx +89 -1
- package/src/extensions/actions.ts +219 -0
- package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.114.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5c18906: Fix "Fix" button on runtime errors not opening agent chat for extensions embedded via `EmbeddedExtension` (e.g. dashboard panels).
|
|
8
|
+
|
|
9
|
+
## 0.114.11
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b47a1b3: Add extension-data-set and extension-data-get agent actions for reading and writing extensionData from the agent side, with auto-refresh of mounted extension iframes after writes.
|
|
14
|
+
|
|
3
15
|
## 0.114.10
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.114.
|
|
3
|
+
"version": "0.114.12",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -177,6 +177,7 @@ export function EmbeddedExtension({
|
|
|
177
177
|
// originates inside the same sandboxed realm as user code) and must be
|
|
178
178
|
// ignored so a viewer cannot self-escalate to owner.
|
|
179
179
|
const bindingLatchedRef = useRef(false);
|
|
180
|
+
const extensionRef = useRef(null as null | Extension);
|
|
180
181
|
|
|
181
182
|
useEffect(() => {
|
|
182
183
|
setIsDark(document.documentElement.classList.contains("dark"));
|
|
@@ -216,6 +217,7 @@ export function EmbeddedExtension({
|
|
|
216
217
|
retry: shouldRetryExtensionLoad,
|
|
217
218
|
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000),
|
|
218
219
|
});
|
|
220
|
+
extensionRef.current = extension ?? null;
|
|
219
221
|
|
|
220
222
|
// Notify the host once when the extension can't be loaded for this viewer so
|
|
221
223
|
// it can show a fallback instead of a blank panel.
|
|
@@ -338,6 +340,92 @@ export function EmbeddedExtension({
|
|
|
338
340
|
return;
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
if (message.type === "agent-native-extension-error-fix") {
|
|
344
|
+
const name = extensionRef.current?.name ?? extensionId;
|
|
345
|
+
const errors: string[] = Array.isArray((message as any).errors)
|
|
346
|
+
? (message as any).errors
|
|
347
|
+
: [];
|
|
348
|
+
const errorDetails: Array<{ message: string; stack: string }> =
|
|
349
|
+
Array.isArray((message as any).errorDetails)
|
|
350
|
+
? (message as any).errorDetails
|
|
351
|
+
: [];
|
|
352
|
+
const consoleLogs: Array<{ level: string; message: string }> =
|
|
353
|
+
Array.isArray((message as any).consoleLogs)
|
|
354
|
+
? (message as any).consoleLogs
|
|
355
|
+
: [];
|
|
356
|
+
const networkLogs: Array<{
|
|
357
|
+
path: string;
|
|
358
|
+
method: string;
|
|
359
|
+
ok?: boolean;
|
|
360
|
+
status?: number;
|
|
361
|
+
error?: string;
|
|
362
|
+
}> = Array.isArray((message as any).networkLogs)
|
|
363
|
+
? (message as any).networkLogs
|
|
364
|
+
: [];
|
|
365
|
+
|
|
366
|
+
const detailedTrace = errorDetails
|
|
367
|
+
.filter((e) => e && typeof e === "object")
|
|
368
|
+
.map((e) => {
|
|
369
|
+
const msg =
|
|
370
|
+
typeof e.message === "string" ? e.message : String(e.message);
|
|
371
|
+
return typeof e.stack === "string" ? `${msg}\n${e.stack}` : msg;
|
|
372
|
+
})
|
|
373
|
+
.join("\n\n");
|
|
374
|
+
|
|
375
|
+
// Force a fresh read from the server, same as ExtensionViewer's fix
|
|
376
|
+
// flow — the query cache may hold the agent's previous (broken) turn.
|
|
377
|
+
let freshContent: string | undefined;
|
|
378
|
+
try {
|
|
379
|
+
const res = await fetch(
|
|
380
|
+
agentNativePath(`/_agent-native/extensions/${extensionId}`),
|
|
381
|
+
{ cache: "no-store" },
|
|
382
|
+
);
|
|
383
|
+
if (res.ok) {
|
|
384
|
+
const fresh = (await res.json()) as Extension;
|
|
385
|
+
freshContent =
|
|
386
|
+
typeof fresh?.content === "string" ? fresh.content : undefined;
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
// Fall through with no snapshot — agent can still re-read via get-extension.
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const contextParts = [
|
|
393
|
+
`The user is viewing a dashboard panel embedding extension "${name}" (id: ${extensionId}, slot: ${slotId}) and there are runtime errors that need fixing.`,
|
|
394
|
+
`\nFull error details:\n${detailedTrace}`,
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
if (consoleLogs.length > 0) {
|
|
398
|
+
const consoleStr = consoleLogs
|
|
399
|
+
.map((l) => `[${l.level}] ${l.message}`)
|
|
400
|
+
.join("\n");
|
|
401
|
+
contextParts.push(`\nRecent console output:\n${consoleStr}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (networkLogs.length > 0) {
|
|
405
|
+
const netStr = networkLogs
|
|
406
|
+
.map(
|
|
407
|
+
(l) =>
|
|
408
|
+
`${l.method} ${l.path} → ${l.ok ? l.status : "FAILED: " + (l.error || l.status)}`,
|
|
409
|
+
)
|
|
410
|
+
.join("\n");
|
|
411
|
+
contextParts.push(`\nRecent network requests:\n${netStr}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (freshContent) {
|
|
415
|
+
contextParts.push(
|
|
416
|
+
`\nCurrent extension content (just re-read from the database — this is the authoritative source, not anything you may have written in a previous turn):\n\`\`\`html\n${freshContent}\n\`\`\``,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
sendToAgentChat({
|
|
421
|
+
message: `Fix runtime errors in extension "${name}" (id: ${extensionId}), embedded as a dashboard panel. The content snapshot below was just re-read from the database — treat it as authoritative and ignore any prior version you may have generated in this chat. If in doubt, call get-extension first.\n\nErrors:\n${errors.join("\n")}`,
|
|
422
|
+
context: contextParts.join("\n"),
|
|
423
|
+
submit: true,
|
|
424
|
+
openSidebar: true,
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
341
429
|
if (message.type !== "agent-native-extension-request") return;
|
|
342
430
|
|
|
343
431
|
const requestId = String(message.requestId ?? "");
|
|
@@ -410,7 +498,7 @@ export function EmbeddedExtension({
|
|
|
410
498
|
|
|
411
499
|
window.addEventListener("message", handleMessage);
|
|
412
500
|
return () => window.removeEventListener("message", handleMessage);
|
|
413
|
-
}, [extensionId]);
|
|
501
|
+
}, [extensionId, slotId]);
|
|
414
502
|
|
|
415
503
|
if (!extension) {
|
|
416
504
|
if (!isLoading && !isFetching) return null;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER } from "../action-ui.js";
|
|
2
4
|
import { AgentActionStopError, type ActionRunContext } from "../action.js";
|
|
3
5
|
import type { ActionEntry } from "../agent/production-agent.js";
|
|
4
6
|
import type { AgentChatAttachment } from "../agent/types.js";
|
|
5
7
|
import { writeAppState } from "../application-state/script-helpers.js";
|
|
8
|
+
import { getDbExec, isPostgres } from "../db/client.js";
|
|
6
9
|
import { readResource } from "../resources/script-helpers.js";
|
|
7
10
|
import {
|
|
8
11
|
getRequestOrgId,
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
import {
|
|
37
40
|
createExtension,
|
|
38
41
|
deleteExtension,
|
|
42
|
+
ensureExtensionsTables,
|
|
39
43
|
findRecentDuplicateExtension,
|
|
40
44
|
getHiddenExtensionIdsForCurrentUser,
|
|
41
45
|
getExtension,
|
|
@@ -45,6 +49,7 @@ import {
|
|
|
45
49
|
hideExtension,
|
|
46
50
|
listExtensionHistory,
|
|
47
51
|
listExtensions,
|
|
52
|
+
notifyExtensionChangeForResource,
|
|
48
53
|
restoreExtensionHistoryVersion,
|
|
49
54
|
unhideExtension,
|
|
50
55
|
updateExtension,
|
|
@@ -818,6 +823,220 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
818
823
|
},
|
|
819
824
|
},
|
|
820
825
|
|
|
826
|
+
"extension-data-set": {
|
|
827
|
+
tool: {
|
|
828
|
+
description:
|
|
829
|
+
"Write a value to an extension's extensionData store (the tool_data table) from the agent side. Use this when the agent needs to update or seed data that an extension reads via extensionData.get() at render time. Requires editor access to the extension.",
|
|
830
|
+
parameters: {
|
|
831
|
+
type: "object",
|
|
832
|
+
properties: {
|
|
833
|
+
extensionId: {
|
|
834
|
+
type: "string",
|
|
835
|
+
description: "Extension id whose data store to write to.",
|
|
836
|
+
},
|
|
837
|
+
collection: {
|
|
838
|
+
type: "string",
|
|
839
|
+
description: "Collection name within the extension's data store.",
|
|
840
|
+
},
|
|
841
|
+
itemId: {
|
|
842
|
+
type: "string",
|
|
843
|
+
description:
|
|
844
|
+
"Item id within the collection. Upserts if it already exists.",
|
|
845
|
+
},
|
|
846
|
+
data: {
|
|
847
|
+
description:
|
|
848
|
+
"The data value to store. Objects are JSON-serialized automatically.",
|
|
849
|
+
},
|
|
850
|
+
scope: {
|
|
851
|
+
type: "string",
|
|
852
|
+
enum: ["user", "org"],
|
|
853
|
+
description:
|
|
854
|
+
"Storage scope. 'user' (default) is private to the current user; 'org' is shared across the organization.",
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
required: ["extensionId", "collection", "itemId", "data"],
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
run: async (args) => {
|
|
861
|
+
const extensionId = String(args?.extensionId ?? "").trim();
|
|
862
|
+
const collection = String(args?.collection ?? "").trim();
|
|
863
|
+
const itemId = String(args?.itemId ?? "").trim();
|
|
864
|
+
if (!extensionId || !collection || !itemId)
|
|
865
|
+
return "Error: extensionId, collection, and itemId are required.";
|
|
866
|
+
if (args?.data === undefined) return "Error: data is required.";
|
|
867
|
+
|
|
868
|
+
await ensureExtensionsTables();
|
|
869
|
+
const access = await resolveAccess("extension", extensionId);
|
|
870
|
+
if (!access || access.role === "viewer")
|
|
871
|
+
return `Error: editor access required for extension ${extensionId}.`;
|
|
872
|
+
|
|
873
|
+
const userEmail = getRequestUserEmail()?.toLowerCase() ?? "";
|
|
874
|
+
const scope = args?.scope === "org" ? "org" : "user";
|
|
875
|
+
const orgId = getRequestOrgId();
|
|
876
|
+
if (scope === "org" && !orgId)
|
|
877
|
+
return "Error: org context required for scope=org.";
|
|
878
|
+
|
|
879
|
+
const data =
|
|
880
|
+
typeof args.data === "string" ? args.data : JSON.stringify(args.data);
|
|
881
|
+
const MAX_BYTES = 1024 * 1024;
|
|
882
|
+
if (Buffer.byteLength(data, "utf8") > MAX_BYTES)
|
|
883
|
+
return `Error: data exceeds ${MAX_BYTES} byte limit. Store large payloads in file storage instead.`;
|
|
884
|
+
|
|
885
|
+
const now = new Date().toISOString();
|
|
886
|
+
const scopeKey = scope === "org" ? `org:${orgId}` : userEmail;
|
|
887
|
+
const client = getDbExec();
|
|
888
|
+
const pg = isPostgres();
|
|
889
|
+
const conflictClause = pg
|
|
890
|
+
? `ON CONFLICT (tool_id, collection, scope_key, item_id)
|
|
891
|
+
DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`
|
|
892
|
+
: `ON CONFLICT (tool_id, collection, scope_key, item_id)
|
|
893
|
+
DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`;
|
|
894
|
+
|
|
895
|
+
await client.execute({
|
|
896
|
+
sql: `UPDATE tools SET updated_at = ? WHERE id = ?`,
|
|
897
|
+
args: [now, extensionId],
|
|
898
|
+
});
|
|
899
|
+
await client.execute({
|
|
900
|
+
sql: `INSERT INTO tool_data (id, tool_id, collection, item_id, data, owner_email, scope, org_id, scope_key, created_at, updated_at)
|
|
901
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
902
|
+
${conflictClause}`,
|
|
903
|
+
args: [
|
|
904
|
+
randomUUID(),
|
|
905
|
+
extensionId,
|
|
906
|
+
collection,
|
|
907
|
+
itemId,
|
|
908
|
+
data,
|
|
909
|
+
userEmail,
|
|
910
|
+
scope,
|
|
911
|
+
scope === "org" ? orgId! : null,
|
|
912
|
+
scopeKey,
|
|
913
|
+
now,
|
|
914
|
+
now,
|
|
915
|
+
],
|
|
916
|
+
});
|
|
917
|
+
await notifyExtensionChangeForResource(extensionId);
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
ok: true,
|
|
921
|
+
id: itemId,
|
|
922
|
+
extensionId,
|
|
923
|
+
collection,
|
|
924
|
+
scope,
|
|
925
|
+
updatedAt: now,
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
"extension-data-get": {
|
|
931
|
+
tool: {
|
|
932
|
+
description:
|
|
933
|
+
"Read items from an extension's extensionData store (the tool_data table) from the agent side. Use this to inspect what data an extension currently has stored, or to verify a write succeeded. Requires viewer access to the extension.",
|
|
934
|
+
parameters: {
|
|
935
|
+
type: "object",
|
|
936
|
+
properties: {
|
|
937
|
+
extensionId: {
|
|
938
|
+
type: "string",
|
|
939
|
+
description: "Extension id whose data store to read from.",
|
|
940
|
+
},
|
|
941
|
+
collection: {
|
|
942
|
+
type: "string",
|
|
943
|
+
description: "Collection name within the extension's data store.",
|
|
944
|
+
},
|
|
945
|
+
itemId: {
|
|
946
|
+
type: "string",
|
|
947
|
+
description:
|
|
948
|
+
"Optional specific item id. If omitted, lists all items in the collection.",
|
|
949
|
+
},
|
|
950
|
+
scope: {
|
|
951
|
+
type: "string",
|
|
952
|
+
enum: ["user", "org", "all"],
|
|
953
|
+
description:
|
|
954
|
+
"Storage scope to read. 'user' (default) reads the current user's items; 'org' reads organization-shared items; 'all' reads both.",
|
|
955
|
+
},
|
|
956
|
+
limit: {
|
|
957
|
+
type: "number",
|
|
958
|
+
description:
|
|
959
|
+
"Maximum items to return when listing (default 100, max 1000).",
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
required: ["extensionId", "collection"],
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
run: async (args) => {
|
|
966
|
+
const extensionId = String(args?.extensionId ?? "").trim();
|
|
967
|
+
const collection = String(args?.collection ?? "").trim();
|
|
968
|
+
if (!extensionId || !collection)
|
|
969
|
+
return "Error: extensionId and collection are required.";
|
|
970
|
+
|
|
971
|
+
await ensureExtensionsTables();
|
|
972
|
+
const access = await resolveAccess("extension", extensionId);
|
|
973
|
+
if (!access) return `Error: no access to extension ${extensionId}.`;
|
|
974
|
+
|
|
975
|
+
const userEmail = getRequestUserEmail()?.toLowerCase() ?? "";
|
|
976
|
+
const scope = args?.scope ?? "user";
|
|
977
|
+
const orgId = getRequestOrgId();
|
|
978
|
+
if ((scope === "org" || scope === "all") && !orgId)
|
|
979
|
+
return "Error: org context required for scope=org or scope=all.";
|
|
980
|
+
const itemId = args?.itemId ? String(args.itemId).trim() : null;
|
|
981
|
+
const limit = Math.min(Math.max(1, Number(args?.limit) || 100), 1000);
|
|
982
|
+
const client = getDbExec();
|
|
983
|
+
|
|
984
|
+
if (itemId) {
|
|
985
|
+
const scopeClause =
|
|
986
|
+
scope === "org"
|
|
987
|
+
? `AND scope = 'org' AND org_id = ?`
|
|
988
|
+
: scope === "all"
|
|
989
|
+
? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))`
|
|
990
|
+
: `AND scope = 'user' AND lower(owner_email) = ?`;
|
|
991
|
+
const scopeArgs =
|
|
992
|
+
scope === "org"
|
|
993
|
+
? [orgId ?? ""]
|
|
994
|
+
: scope === "all"
|
|
995
|
+
? [userEmail, orgId ?? ""]
|
|
996
|
+
: [userEmail];
|
|
997
|
+
|
|
998
|
+
const result = await client.execute({
|
|
999
|
+
sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at
|
|
1000
|
+
FROM tool_data
|
|
1001
|
+
WHERE tool_id = ? AND collection = ? AND COALESCE(item_id, id) = ? ${scopeClause}`,
|
|
1002
|
+
args: [extensionId, collection, itemId, ...scopeArgs],
|
|
1003
|
+
});
|
|
1004
|
+
const row = result.rows?.[0];
|
|
1005
|
+
if (!row) return { found: false, extensionId, collection, itemId };
|
|
1006
|
+
return { found: true, ...row };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const scopeClause =
|
|
1010
|
+
scope === "org"
|
|
1011
|
+
? `AND scope = 'org' AND org_id = ?`
|
|
1012
|
+
: scope === "all"
|
|
1013
|
+
? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))`
|
|
1014
|
+
: `AND scope = 'user' AND lower(owner_email) = ?`;
|
|
1015
|
+
const scopeArgs =
|
|
1016
|
+
scope === "org"
|
|
1017
|
+
? [orgId ?? ""]
|
|
1018
|
+
: scope === "all"
|
|
1019
|
+
? [userEmail, orgId ?? ""]
|
|
1020
|
+
: [userEmail];
|
|
1021
|
+
|
|
1022
|
+
const result = await client.execute({
|
|
1023
|
+
sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at
|
|
1024
|
+
FROM tool_data
|
|
1025
|
+
WHERE tool_id = ? AND collection = ? ${scopeClause}
|
|
1026
|
+
ORDER BY updated_at DESC
|
|
1027
|
+
LIMIT ?`,
|
|
1028
|
+
args: [extensionId, collection, ...scopeArgs, limit],
|
|
1029
|
+
});
|
|
1030
|
+
return {
|
|
1031
|
+
extensionId,
|
|
1032
|
+
collection,
|
|
1033
|
+
scope,
|
|
1034
|
+
count: result.rows?.length ?? 0,
|
|
1035
|
+
items: result.rows ?? [],
|
|
1036
|
+
};
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
|
|
821
1040
|
"delete-extension": {
|
|
822
1041
|
tool: {
|
|
823
1042
|
description:
|
|
@@ -153,6 +153,25 @@ persistence** — it handles everything automatically. Only use
|
|
|
153
153
|
`dbQuery`/`dbExec` when querying the app's existing tables. See
|
|
154
154
|
`references/api.md` for the full `get`/`remove`/scope reference.
|
|
155
155
|
|
|
156
|
+
### Agent-side extension data access
|
|
157
|
+
|
|
158
|
+
The agent can read and write `extensionData` directly using two dedicated
|
|
159
|
+
actions — no need to go through the iframe bridge or raw SQL:
|
|
160
|
+
|
|
161
|
+
| Action | Purpose |
|
|
162
|
+
| --------------------- | ------------------------------------------------- |
|
|
163
|
+
| `extension-data-set` | Upsert an item in an extension's data store |
|
|
164
|
+
| `extension-data-get` | Read items from an extension's data store |
|
|
165
|
+
|
|
166
|
+
Use `extension-data-set` when the agent needs to seed, refresh, or update
|
|
167
|
+
data that an extension reads at render time via `extensionData.get()`. This
|
|
168
|
+
is the correct path for agent-driven dashboard refreshes — the agent
|
|
169
|
+
fetches fresh data from providers, then writes the merged result with
|
|
170
|
+
`extension-data-set`, and the extension picks it up on next load.
|
|
171
|
+
|
|
172
|
+
Use `extension-data-get` to inspect what data an extension currently stores,
|
|
173
|
+
or to verify a write succeeded.
|
|
174
|
+
|
|
156
175
|
## What extensions are
|
|
157
176
|
|
|
158
177
|
Extensions are mini Alpine.js apps that run inside sandboxed iframes. They
|
|
@@ -277,9 +277,18 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
277
277
|
// without this guard that effect would treat the two URLs as the "same
|
|
278
278
|
// resource" and immediately revert our retry before `.load()` completes.
|
|
279
279
|
const recoveringFromErrorRef = useRef(false);
|
|
280
|
+
const prevMseModeRef = useRef("");
|
|
281
|
+
// Render-phase mirrors of currentMs / isPlaying so the MSE-fallback effect
|
|
282
|
+
// below can read the pre-failure values. By the time effects run, React has
|
|
283
|
+
// already committed the new <video src>, causing the browser to reset
|
|
284
|
+
// currentTime -> 0 and paused -> true, making the element values useless.
|
|
285
|
+
const currentMsRef = useRef(startMs ?? 0);
|
|
286
|
+
const isPlayingRef = useRef(false);
|
|
280
287
|
const [activeVideoSrc, setActiveVideoSrc] = useState(resolvedVideoSrc);
|
|
281
288
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
282
289
|
const [currentMs, setCurrentMs] = useState(startMs ?? 0);
|
|
290
|
+
currentMsRef.current = currentMs;
|
|
291
|
+
isPlayingRef.current = isPlaying;
|
|
283
292
|
const [loomStartMs, setLoomStartMs] = useState<number | null>(null);
|
|
284
293
|
const [volume, setVolume] = useState(1);
|
|
285
294
|
// Autoplaying players (e.g. the Slack unfurl embed, `?autoplay=1`) must
|
|
@@ -392,6 +401,45 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
392
401
|
? undefined
|
|
393
402
|
: activeVideoSrc;
|
|
394
403
|
|
|
404
|
+
// When the MSE pipeline breaks mid-stream (premature 416 or
|
|
405
|
+
// ERR_CONTENT_LENGTH_MISMATCH after the backing GCS object is replaced by
|
|
406
|
+
// the compressed version), the loader calls onFatal which flips mse.mode to
|
|
407
|
+
// "native". This effect detects that transition and:
|
|
408
|
+
// 1. saves the playback position via currentMsRef (v.currentTime is already 0)
|
|
409
|
+
// 2. cache-busts activeVideoSrc so the native <video> path fetches fresh
|
|
410
|
+
// headers rather than a proxy-cached content-length from the old object
|
|
411
|
+
// 3. if the user was playing, arms playAttemptPendingRef so the existing
|
|
412
|
+
// retryPendingPlay call in onLoadedData resumes without user interaction
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
const prev = prevMseModeRef.current;
|
|
415
|
+
prevMseModeRef.current = mse.mode;
|
|
416
|
+
if (prev !== "mse" || mse.mode !== "native") return;
|
|
417
|
+
|
|
418
|
+
const wasPlaying = isPlayingRef.current;
|
|
419
|
+
const posMs = currentMsRef.current > 0 ? currentMsRef.current : null;
|
|
420
|
+
if (posMs != null) resumeAfterReloadMsRef.current = posMs;
|
|
421
|
+
|
|
422
|
+
if (activeVideoSrc) {
|
|
423
|
+
recoveringFromErrorRef.current = true;
|
|
424
|
+
setActiveVideoSrc(
|
|
425
|
+
setUrlSearchParam(activeVideoSrc, "cb", String(Date.now())),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (wasPlaying) {
|
|
430
|
+
const nextId = playAttemptIdRef.current + 1;
|
|
431
|
+
playAttemptIdRef.current = nextId;
|
|
432
|
+
playAttemptPendingRef.current = true;
|
|
433
|
+
setIsPlayPending(true);
|
|
434
|
+
setIsBuffering(true);
|
|
435
|
+
} else {
|
|
436
|
+
setIsPlaying(false);
|
|
437
|
+
setIsBuffering(false);
|
|
438
|
+
}
|
|
439
|
+
setIsPreparing(true);
|
|
440
|
+
setCanPlay(false);
|
|
441
|
+
}, [mse.mode, activeVideoSrc]);
|
|
442
|
+
|
|
395
443
|
useEffect(() => {
|
|
396
444
|
if (!resolvedVideoSrc) {
|
|
397
445
|
setActiveVideoSrc(undefined);
|
|
@@ -208,14 +208,36 @@ export function findMoofOffset(bytes: Uint8Array): number {
|
|
|
208
208
|
* True when the head of an MP4 shows the fragmented shape: the `hlsf` brand in
|
|
209
209
|
* `ftyp`, or an `mvex` box inside `moov` (present only in fragmented files).
|
|
210
210
|
* `headBytes` should be the first few KB of the file.
|
|
211
|
+
*
|
|
212
|
+
* Both checks walk the ISO BMFF box structure rather than scanning raw bytes.
|
|
213
|
+
* Numeric fields inside moov children (timestamps, matrix values, codec config)
|
|
214
|
+
* can accidentally contain the byte sequences "mvex" or "hlsf", producing false
|
|
215
|
+
* positives if we just scan the whole buffer.
|
|
211
216
|
*/
|
|
212
217
|
export function isFragmentedMp4Head(headBytes: Uint8Array): boolean {
|
|
213
218
|
if (headBytes.byteLength < 8) return false;
|
|
214
|
-
|
|
219
|
+
|
|
215
220
|
if (indexOfAscii(headBytes, "ftyp") !== 4) return false;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return false;
|
|
221
|
+
|
|
222
|
+
const ftypSize = readU32(headBytes, 0);
|
|
223
|
+
if (ftypSize < 12) return false;
|
|
224
|
+
const ftypEnd = Math.min(ftypSize, headBytes.byteLength);
|
|
225
|
+
|
|
226
|
+
// Offset 12 is minor_version (uint32), not a brand — start compatible brands at 16.
|
|
227
|
+
if (ftypEnd >= 12 && readType(headBytes, 8) === "hlsf") return true;
|
|
228
|
+
for (let i = 16; i + 4 <= ftypEnd; i += 4) {
|
|
229
|
+
if (readType(headBytes, i) === "hlsf") return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const boxes = readTopLevelBoxes(headBytes);
|
|
233
|
+
const moov = boxes.find((b) => b.type === "moov");
|
|
234
|
+
if (!moov || moov.size === 0) return false;
|
|
235
|
+
const moovPayloadEnd = Math.min(moov.start + moov.size, headBytes.byteLength);
|
|
236
|
+
const moovPayload = headBytes.subarray(
|
|
237
|
+
moov.start + moov.headerSize,
|
|
238
|
+
moovPayloadEnd,
|
|
239
|
+
);
|
|
240
|
+
return readTopLevelBoxes(moovPayload).some((b) => b.type === "mvex");
|
|
219
241
|
}
|
|
220
242
|
|
|
221
243
|
/** Cache detection by URL identity so we sniff each asset only once. */
|
|
@@ -555,11 +555,22 @@ export class MseVideoLoader {
|
|
|
555
555
|
headers: { Range: `bytes=${start}-${end}` },
|
|
556
556
|
signal: controller.signal,
|
|
557
557
|
});
|
|
558
|
-
|
|
559
|
-
|
|
558
|
+
if (res.status === 416) {
|
|
559
|
+
// A 416 at an offset before the known file total means the backing object
|
|
560
|
+
// was replaced with a smaller compressed version while we were streaming.
|
|
561
|
+
// Throw so runPump's catch calls fail() -> onFatal -> native path recovery.
|
|
562
|
+
// When totalKnown is false (cross-origin CDN hides Content-Range), we
|
|
563
|
+
// cannot tell premature from real EOF so we keep the old safe-EOF behaviour.
|
|
564
|
+
if (this.totalKnown && start < this.totalBytes) {
|
|
565
|
+
throw new Error("Range 416 before known EOF: backing file replaced");
|
|
566
|
+
}
|
|
567
|
+
return { bytes: new Uint8Array(0), eof: true };
|
|
568
|
+
}
|
|
560
569
|
if (!res.ok) {
|
|
561
570
|
throw new Error(`Range request failed: ${res.status}`);
|
|
562
571
|
}
|
|
572
|
+
// If the backing file shrank mid-response (ERR_CONTENT_LENGTH_MISMATCH),
|
|
573
|
+
// arrayBuffer() throws here, which also routes through fail() -> onFatal.
|
|
563
574
|
const buffer = await res.arrayBuffer();
|
|
564
575
|
const bytes = new Uint8Array(buffer);
|
|
565
576
|
|