stillmade-sdk 0.1.34 → 0.1.36
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/AGENTS.md +8 -4
- package/DESKTOP_CAPABILITIES.md +81 -6
- package/FRAME_VIEWS.md +5 -0
- package/MODULES.md +104 -11
- package/PASTE_TO_LLM.md +14 -6
- package/SDK.md +245 -50
- package/blocks/connected-app/versions/1.6.0/view.js +27 -0
- package/blocks/gmail/versions/1.1.0/workspace.js +15 -0
- package/blocks/native/sm.when/versions/1.1.0/package.js +216 -0
- package/package.json +1 -1
- package/packages/block-cli/cli.js +27 -32
- package/packages/block-cli/dev-host/assets/host.js +38 -37
- package/packages/block-cli/dev-host/assets/index.css +1 -1
- package/packages/block-cli/dev-host/index.html +1 -0
- package/packages/block-cli/format.js +8 -0
- package/packages/block-platform/catalog.js +17 -1
- package/packages/block-platform/segment-runner.js +10 -4
- package/packages/block-platform/steps.js +6 -0
- package/packages/block-sdk/admission.js +11 -4
- package/packages/block-sdk/builder-contract.js +10 -5
- package/packages/block-sdk/capabilities.js +3 -0
- package/packages/block-sdk/connected-app.js +1 -1
- package/packages/block-sdk/contracts.js +1 -0
- package/packages/block-sdk/desktop-permissions.js +1 -1
- package/packages/block-sdk/first-party-identities.js +1 -0
- package/packages/block-sdk/first-party-packages.js +2 -1
- package/packages/block-sdk/folder-package.js +75 -0
- package/packages/block-sdk/index.d.ts +32 -6
- package/packages/block-sdk/index.js +4 -0
- package/packages/block-sdk/local-admission.js +4 -2
- package/packages/block-sdk/media-transform.js +42 -2
- package/packages/block-sdk/mock-host.js +3 -16
- package/packages/block-sdk/mocks.js +20 -0
- package/packages/block-sdk/module-host-calls.js +23 -0
- package/packages/block-sdk/module-hosted.js +22 -1
- package/packages/block-sdk/module-node.js +15 -20
- package/packages/block-sdk/module-runtime.js +7 -4
- package/packages/block-sdk/module-sockets.js +66 -0
- package/packages/block-sdk/module-worker.js +38 -1
- package/packages/block-sdk/network.js +20 -0
- package/packages/block-sdk/permission-summary.js +97 -0
- package/packages/block-sdk/permission-usage.js +72 -0
- package/packages/block-sdk/port-types.js +1 -1
- package/packages/block-sdk/schema-fields.js +20 -5
- package/packages/block-sdk/testing.js +20 -7
- package/packages/block-sdk/view-bridge.js +2 -1
- package/blocks/connected-app/versions/1.5.0/view.js +0 -18
- package/blocks/gmail/versions/1.0.0/workspace.js +0 -6
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// One reader for a Block folder, the layout `stillmade-block create` writes:
|
|
2
|
+
// stillmade.block.json, src/, tests/fixtures.json, and for module Blocks module/
|
|
3
|
+
// and module.json. The CLI's pack, dev and test and the app's Import folder both
|
|
4
|
+
// use it, so a folder made anywhere (the CLI, Codex, Claude Code, by hand) imports
|
|
5
|
+
// exactly as the CLI packs it. Pure JavaScript: the caller supplies the files.
|
|
6
|
+
import {VIEW_SOURCE_FILES,VIEW_CONFIG_FILE,viewFromSource} from './view-source.js';
|
|
7
|
+
import {describeViewFiles} from './view.js';
|
|
8
|
+
import {describeModuleFiles,MODULE_LIMITS} from './module-runtime.js';
|
|
9
|
+
|
|
10
|
+
export const BLOCK_FOLDER_MANIFEST='stillmade.block.json';
|
|
11
|
+
export const BLOCK_FOLDER_LIMITS=Object.freeze({jsonBytes:4000000,javascriptBytes:262144,viewScriptBytes:4194304,viewSourceBytes:262144});
|
|
12
|
+
const SOURCE=/^src\/(run\.js|recipe\.json|comfyui\.json|capability\.json|desktop\.json|view\.json|view\.config\.json|view\.(?:html|css|js)|view\.themed\.css)$/;
|
|
13
|
+
|
|
14
|
+
/** Whether a path relative to a Block folder belongs to the Block. Everything
|
|
15
|
+
* else (node_modules, build output, notes, .git) is left out. */
|
|
16
|
+
export function blockFolderPath(path){
|
|
17
|
+
return path===BLOCK_FOLDER_MANIFEST||path==='module.json'||path==='tests/fixtures.json'||SOURCE.test(path)||path.startsWith('module/')||path.startsWith('src/view-files/');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const missing=path=>Object.assign(new Error(`Missing ${path}`),{code:'ENOENT'});
|
|
21
|
+
const decoder=new TextDecoder('utf-8',{fatal:true});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The package for a Block folder. `read(path)` resolves a file's bytes, or
|
|
25
|
+
* undefined when it does not exist; `list(prefix)` resolves {relativePath: bytes}
|
|
26
|
+
* for every file under a folder such as 'module/' ({} when there is none).
|
|
27
|
+
* Returns {pkg, files}: files holds module files by path and frame view assets as
|
|
28
|
+
* `view:<path>`, for tests, previews and upload; null when there are none.
|
|
29
|
+
*/
|
|
30
|
+
export async function packageFromFolder({read,list}){
|
|
31
|
+
const bytes=async(path,limit,message)=>{const value=await read(path);if(value===undefined)return undefined;if(value.byteLength>limit)throw new Error(message||`${path} exceeds ${Math.round(limit/1000000)} MB`);return value;};
|
|
32
|
+
const json=async(path,{optional=false}={})=>{const value=await bytes(path,BLOCK_FOLDER_LIMITS.jsonBytes,'Package file exceeds 4 MB');if(value===undefined){if(optional)return undefined;throw missing(path);}return JSON.parse(decoder.decode(value));};
|
|
33
|
+
const folder=async prefix=>{
|
|
34
|
+
const files=await list(prefix);let total=0;
|
|
35
|
+
for(const value of Object.values(files)){total+=value.byteLength;if(value.byteLength>MODULE_LIMITS.fileBytes)throw new Error('A module file exceeds 64 MiB');}
|
|
36
|
+
if(Object.keys(files).length>MODULE_LIMITS.files||total>MODULE_LIMITS.totalBytes)throw new Error('The module folder exceeds 64 files or 128 MiB');
|
|
37
|
+
return files;
|
|
38
|
+
};
|
|
39
|
+
const manifest=await json(BLOCK_FOLDER_MANIFEST);
|
|
40
|
+
let source,moduleFiles=null;
|
|
41
|
+
if(manifest.runtime==='module'){
|
|
42
|
+
const config=await json('module.json',{optional:true})||{};
|
|
43
|
+
moduleFiles=await folder('module/');
|
|
44
|
+
source={module:await describeModuleFiles(config.entry||'main.js',moduleFiles,{threads:config.threads===true})};
|
|
45
|
+
}else if(manifest.runtime==='javascript'){
|
|
46
|
+
const code=await bytes('src/run.js',BLOCK_FOLDER_LIMITS.javascriptBytes,'JavaScript exceeds 256 KiB');
|
|
47
|
+
if(code===undefined)throw missing('src/run.js');
|
|
48
|
+
source={code:decoder.decode(code)};
|
|
49
|
+
}else if(manifest.runtime==='comfyui')source={comfyui:await json('src/comfyui.json')};
|
|
50
|
+
else if(manifest.runtime==='capability')source={capability:await json('src/capability.json')};
|
|
51
|
+
else source={recipe:await json('src/recipe.json')};
|
|
52
|
+
// A frame view's script may be a framework bundle (up to 4 MiB).
|
|
53
|
+
const sources={};
|
|
54
|
+
for(const name of ['view.json',VIEW_CONFIG_FILE,...Object.keys(VIEW_SOURCE_FILES)]){
|
|
55
|
+
const value=await bytes(`src/${name}`,name==='view.js'?BLOCK_FOLDER_LIMITS.viewScriptBytes:BLOCK_FOLDER_LIMITS.viewSourceBytes,`Interface source ${name} is too large`);
|
|
56
|
+
if(value!==undefined)sources[name]=decoder.decode(value);
|
|
57
|
+
}
|
|
58
|
+
const view=viewFromSource(sources);
|
|
59
|
+
// A frame view's assets live in src/view-files/ and are named by SHA-256.
|
|
60
|
+
let viewFiles=null;
|
|
61
|
+
if(view?.runtime==='frame'){
|
|
62
|
+
const assets=await folder('src/view-files/');
|
|
63
|
+
if(Object.keys(assets).length){view.files=await describeViewFiles(assets);viewFiles=Object.fromEntries(Object.entries(assets).map(([name,value])=>[`view:${name}`,value]));}
|
|
64
|
+
}
|
|
65
|
+
const desktop=await json('src/desktop.json',{optional:true});
|
|
66
|
+
const pkg={manifest,...source,...(desktop!==undefined?{desktop}:{}),...(view!==undefined?{view}:{}),tests:await json('tests/fixtures.json')};
|
|
67
|
+
return {pkg,files:moduleFiles||viewFiles?{...(moduleFiles||{}),...(viewFiles||{})}:null};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The file bytes a package names, keyed by SHA-256, for upload or a module bundle. */
|
|
71
|
+
export function packageFilesBySha(pkg,files){
|
|
72
|
+
const bySha={};
|
|
73
|
+
for(const [name,file] of [...Object.entries(pkg.module?.files||{}),...Object.entries(pkg.view?.files?.files||{}).map(([name,file])=>[`view:${name}`,file])])if(files?.[name])bySha[file.sha256]=files[name];
|
|
74
|
+
return bySha;
|
|
75
|
+
}
|
|
@@ -144,7 +144,7 @@ export const VIEW_LIMITS: Readonly<{htmlBytes:65536;cssBytes:32768;javascriptByt
|
|
|
144
144
|
export interface BlockImagePreview { url: `data:image/png;base64,${string}`; width: number; height: number; }
|
|
145
145
|
export interface BlockImagePixels { width: number; height: number; data: number[]; role?: string; }
|
|
146
146
|
export interface BlockImageReference { kind: 'image'; assetId: string; versionId: string; url: string; role?: string; }
|
|
147
|
-
export type DesktopPermission = "screen.capture"|"cursor.track"|"camera.capture"|"microphone.capture"|"clipboard.read"|"clipboard.write"|"media.pick"|"notifications.show"|"power.keep-awake"|"native.tools";
|
|
147
|
+
export type DesktopPermission = "screen.capture"|"cursor.track"|"camera.capture"|"microphone.capture"|"clipboard.read"|"clipboard.write"|"media.pick"|"notifications.show"|"power.keep-awake"|"native.tools"|"folder.read"|"folder.write";
|
|
148
148
|
export type DesktopToolDeclaration = {id:'org.natron.Natron';adapterVersion:1;actions:('launch'|'project.import'|'project.open'|'project.render')[]} | {id:'org.stillmade.PythonAdapter';adapterVersion:1;actions:('adapter.import'|'adapter.inspect'|'adapter.execute'|'adapter.describe'|'adapter.call')[]};
|
|
149
149
|
export type DesktopExecution = {schemaVersion:1;tool:'org.natron.Natron';action:'project.render';arguments:Record<'projectId'|'input'|'reader'|'writer'|'firstFrame'|'lastFrame',{input:string}>;outputs:Record<string,'asset'>} | {schemaVersion:1;tool:'org.stillmade.PythonAdapter';action:'adapter.execute';arguments:Record<'adapterId'|'sourceDigest'|'functionId'|'input',{input:string}>;outputs:Record<string,'result'>} | {schemaVersion:2;tool:'org.stillmade.PythonAdapter';action:'adapter.call';source:{code:string;digest:string;license:string};functionId:string;arguments:Record<string,{input:string}>;outputs:{result:'result'}};
|
|
150
150
|
export interface DesktopExecutionRequest {schemaVersion:1;requestId:string;packageDigest:string;inputDigest:string;tools:DesktopToolDeclaration[]}
|
|
@@ -169,7 +169,7 @@ export interface BlockViewNode { tag: BlockViewTag; text?: string; children?: Bl
|
|
|
169
169
|
export interface BlockViewAction { action: string; value: string; checked: boolean; event: 'click'|'input'|'change'; }
|
|
170
170
|
export interface BlockMediaReference { kind: 'video'|'audio'; assetId: string; versionId: string; url: string; role?: string; }
|
|
171
171
|
export interface BlockMediaPreview { kind: 'video'|'audio'; url: string; mimeType: string; bytes: number; }
|
|
172
|
-
export interface BlockViewBridge { useOutput(outputs: Record<string, BlockMediaReference|BlockMediaReference[]>): Promise<Record<string, unknown>>; previewMedia(value: BlockMediaReference): Promise<BlockMediaPreview>; releaseMedia(url: string): void; render(targetId: string, tree: BlockViewNode[]): void; onAction(callback: (event: BlockViewAction) => void|Promise<void>): () => void; desktop(operation: DesktopPermission|DesktopToolOperation|import('./block-storage.js').BlockStorageOperation|"record.start"|"record.stop"|"record.cancel"|"session.close", args?: Record<string,unknown>): Promise<Record<string,unknown>>; readonly input: Record<string, unknown>; onInput(callback: (input: Record<string, unknown>) => void): () => void; run(input: Record<string, unknown>): Promise<Record<string, unknown>>; previewImage(value: BlockImageReference|BlockImagePixels): Promise<BlockImagePreview>; }
|
|
172
|
+
export interface BlockViewBridge { useOutput(outputs: Record<string, BlockMediaReference|BlockMediaReference[]>): Promise<Record<string, unknown>>; previewMedia(value: BlockMediaReference): Promise<BlockMediaPreview>; releaseMedia(url: string): void; render(targetId: string, tree: BlockViewNode[]): void; onAction(callback: (event: BlockViewAction) => void|Promise<void>): () => void; desktop(operation: DesktopPermission|DesktopToolOperation|import('./block-storage.js').BlockStorageOperation|"record.start"|"record.stop"|"record.cancel"|"session.close"|"live.start"|"live.stop"|"live.status"|"folder.pick"|"folder.shared"|"folder.forget"|"folder.list", args?: Record<string,unknown>): Promise<Record<string,unknown>>; readonly input: Record<string, unknown>; onInput(callback: (input: Record<string, unknown>) => void): () => void; run(input: Record<string, unknown>): Promise<Record<string, unknown>>; previewImage(value: BlockImageReference|BlockImagePixels): Promise<BlockImagePreview>; }
|
|
173
173
|
|
|
174
174
|
/** Validate hosted UI colors independently of the preserved original stylesheet. */
|
|
175
175
|
export function validateViewAppearance(view?: BlockView, options?: {appearance?:'host'|'original'}): true;
|
|
@@ -575,9 +575,14 @@ export interface ConnectionConfigurationResult {schemaVersion:1;operation:'reloa
|
|
|
575
575
|
export function configuredConnectionBlock(app:ConnectedAppMetadata,route:ConnectedTaskMetadata,basePackage:BlockPackage,configuration:ConnectionConfigurationResult):Promise<BlockPackage>;
|
|
576
576
|
export function validateConnectedCapability(capability:ConnectedCapability,manifest:Manifest):true;
|
|
577
577
|
export function prepareConnectedInvocation(pkg:BlockPackage,raw:Record<string,unknown>):{operation:'connection.execute';appId:string;capabilityId:string;pin:ConnectionPin;input:Record<string,unknown>};
|
|
578
|
-
export interface SchemaFields {currencyDigits(currency:string):number;minorUnits(decimal:string,currency:string):number;formatMoney(minor:number,currency:string):string;preview(value:unknown,options?:{maximumFields?:number}):BlockViewNode[];read(root:unknown,path:Array<string|number>):unknown;set(root:unknown,path:Array<string|number>,value:unknown):Record<string,unknown>;controls(schema:Record<string,unknown>,bindings:Record<string,string>,values:Record<string,unknown>,options?:{canEdit?:boolean;busy?:boolean}):{tree:unknown[];actions:unknown[]};change(values:Record<string,unknown>,actions:unknown[],event:unknown):Record<string,unknown>;fromState(input:Record<string,unknown>,state:Record<string,unknown>,bindings:Record<string,string>):Record<string,unknown>;edits(values:Record<string,unknown>,state:Record<string,unknown>,bindings:Record<string,string>):{patch:Record<string,unknown>;before:Record<string,unknown>;changed:boolean}}
|
|
578
|
+
export interface SchemaFields {currencyDigits(currency:string):number;minorUnits(decimal:string,currency:string):number;formatMoney(minor:number,currency:string):string;preview(value:unknown,options?:{maximumFields?:number}):BlockViewNode[];read(root:unknown,path:Array<string|number>):unknown;set(root:unknown,path:Array<string|number>,value:unknown):Record<string,unknown>;controls(schema:Record<string,unknown>,bindings:Record<string,string>,values:Record<string,unknown>,options?:{canEdit?:boolean;busy?:boolean;more?:string[];hide?:string[]}):{tree:unknown[];actions:unknown[]};change(values:Record<string,unknown>,actions:unknown[],event:unknown):Record<string,unknown>;fromState(input:Record<string,unknown>,state:Record<string,unknown>,bindings:Record<string,string>):Record<string,unknown>;edits(values:Record<string,unknown>,state:Record<string,unknown>,bindings:Record<string,string>):{patch:Record<string,unknown>;before:Record<string,unknown>;changed:boolean}}
|
|
579
579
|
export function createSchemaFields(lockedInputKeys?:string[]):Readonly<SchemaFields>;
|
|
580
580
|
export interface BlockViewBridge {schemaFields:Readonly<SchemaFields>}
|
|
581
|
+
export interface BlockLiveFrameInfo {index:number;timestamp:number;width:number;height:number}
|
|
582
|
+
export interface BlockLiveAudioInfo {index:number;timestamp:number;sampleRate:number}
|
|
583
|
+
/** The camera and microphone as they happen (`camera.capture`, `microphone.capture`); nothing reaches the view before the person presses Start. */
|
|
584
|
+
export interface BlockLiveFeed {start(options:{camera?:boolean;microphone?:boolean;fps?:number;width?:number;sampleRate?:number}):Promise<{live:true;camera:boolean;microphone:boolean;fps:number;width:number;height:number;sampleRate:number}>;stop():Promise<{live:false}>;status():Promise<{live:boolean;[key:string]:unknown}>;onFrame(callback:(frame:ImageBitmap,info:BlockLiveFrameInfo)=>void):()=>void;onAudio(callback:(samples:Float32Array,info:BlockLiveAudioInfo)=>void):()=>void;onEnd(callback:(event:{reason:string})=>void):()=>void}
|
|
585
|
+
export interface BlockViewBridge {readonly live:BlockLiveFeed}
|
|
581
586
|
|
|
582
587
|
export interface ConnectionReferencePointer {id:string;version:string}
|
|
583
588
|
export interface ConnectionReferenceField {path:Array<string|number>;value:string|number|boolean|null}
|
|
@@ -617,7 +622,9 @@ export function parseJsonReply(text: string): unknown;
|
|
|
617
622
|
|
|
618
623
|
/** Runtime `module`: an ES module (with optional WebAssembly and data files) named by SHA-256. */
|
|
619
624
|
export interface ModuleFile { sha256: string; bytes: number; mediaType: 'text/javascript'|'application/wasm'|'application/json'|'text/plain'|'image/png'|'image/jpeg'|'image/webp'|'font/woff2'|'application/octet-stream'; }
|
|
620
|
-
export interface ModuleDescriptor { schemaVersion: 1; entry: string; files: Record<string, ModuleFile>; }
|
|
625
|
+
export interface ModuleDescriptor { schemaVersion: 1; entry: string; files: Record<string, ModuleFile>; /** Run in a Worker that shares memory with the Workers it starts (no WebCodecs, WebGPU or crypto.subtle). */ threads?: true; }
|
|
626
|
+
/** A live WebSocket a module opened with stillmade.net.connect. */
|
|
627
|
+
export interface ModuleLiveSocket { url: string; protocol: string; readonly readyState: number; onmessage: ((event: {data: string | ArrayBuffer}) => void) | null; onclose: ((event: {code: number; reason: string; wasClean: boolean}) => void) | null; send(data: string | ArrayBuffer | ArrayBufferView | Blob): Promise<unknown>; close(code?: number, reason?: string): Promise<{code: number; reason: string; wasClean: boolean}>; closed: Promise<{code: number; reason: string; wasClean: boolean}>; addEventListener(type: 'message' | 'close', listener: (event: Event & Record<string, unknown>) => void): void; removeEventListener(type: 'message' | 'close', listener: (event: Event & Record<string, unknown>) => void): void; [Symbol.asyncIterator](): AsyncIterator<string | ArrayBuffer>}
|
|
621
628
|
export const MODULE_RUNTIME_VERSION: 1;
|
|
622
629
|
export const MODULE_LIMITS: Readonly<{files:64;fileBytes:67108864;totalBytes:134217728;path:160;wallClockMs:120000;maxWallClockMs:1800000;progressGraceMs:60000;messageBytes:16777216;logEntries:500}>;
|
|
623
630
|
export const MODULE_MEDIA_TYPES: readonly ModuleFile['mediaType'][];
|
|
@@ -643,8 +650,8 @@ export interface ModuleHost {
|
|
|
643
650
|
media: { transform(request: MediaTransformRequest): Promise<SavedMediaRef|DecodedAudio>; };
|
|
644
651
|
actions: { run(action: Record<string, unknown>): Promise<unknown>; };
|
|
645
652
|
hosted: { run(operation: string, request: Record<string, unknown>): Promise<unknown>; };
|
|
646
|
-
/** Declared-origin network calls; StillMade adds the person's own key. */
|
|
647
|
-
net: { fetch(url: string|URL, init?: {method?: string; headers?: Record<string,string>|[string,string][]; body?: ArrayBuffer|ArrayBufferView|Blob|string}): Promise<Response>; };
|
|
653
|
+
/** Declared-origin network calls; StillMade adds the person's own key. `connect` opens a live WebSocket to a declared origin's wss:// form (no key). */
|
|
654
|
+
net: { fetch(url: string|URL, init?: {method?: string; headers?: Record<string,string>|[string,string][]; body?: ArrayBuffer|ArrayBufferView|Blob|string}): Promise<Response>; connect(url: string|URL, options?: {protocols?: string|string[]}): Promise<ModuleLiveSocket>; };
|
|
648
655
|
/** Published adapter operations, with the person's own account. */
|
|
649
656
|
connections: { call(appId: string, operation: string, input?: Record<string, unknown>): Promise<unknown>; };
|
|
650
657
|
/** The Block's cloud storage in this project (manifest `storage`). */
|
|
@@ -701,3 +708,22 @@ export const NATIVE_APPLICATION_RUNTIME_REQUIREMENT:string;
|
|
|
701
708
|
/** Validates a native application package's strict source syntax and returns its detached, frozen copy. Throws NATIVE_APPLICATION_PACKAGE errors. Not runtime admission. */
|
|
702
709
|
export function defineNativeApplicationPackage<T=unknown>(value:T):T;
|
|
703
710
|
export function validateNativeApplicationPackage(value:unknown):true;
|
|
711
|
+
export const PERMISSION_LEVELS:readonly ["none","low","medium","high"];
|
|
712
|
+
export type PermissionLevel="none"|"low"|"medium"|"high";
|
|
713
|
+
export interface PermissionLine { id:string; text:string; level:PermissionLevel; family:"project"|"service"|"network"|"device" }
|
|
714
|
+
/** What a Block can do, in plain words, with the level of care it needs. The host refuses anything a Block did not declare, so this is all it can reach. */
|
|
715
|
+
export function summarizePermissions(manifest:Manifest):{ level:PermissionLevel; lines:PermissionLine[]; dataLeaves:boolean; staysInStillMade:boolean; headline:string };
|
|
716
|
+
/** What an update adds or removes, by line, and whether its level rose. */
|
|
717
|
+
export function permissionChanges(previous:Manifest,next:Manifest):{ added:PermissionLine[]; removed:PermissionLine[]; levelRaised:boolean; before:ReturnType<typeof summarizePermissions>; after:ReturnType<typeof summarizePermissions> };
|
|
718
|
+
/** What a Block's source uses compared with its manifest: desktop operations and network origins used but not declared, or declared and never used. Advice from literal names; never a gate. */
|
|
719
|
+
export function permissionUsage(pkg:BlockPackage,files?:Record<string,Uint8Array|string>):{ desktop:string[]; origins:string[]; undeclared:{family:"desktop"|"network";scope:string}[]; unused:{family:"desktop"|"network";scope:string}[] };
|
|
720
|
+
/** The manifest declaring what the source uses and nothing else. */
|
|
721
|
+
export function fixPermissions(manifest:Manifest,usage:ReturnType<typeof permissionUsage>):Manifest;
|
|
722
|
+
/** The package for a Block folder (the layout stillmade-block create writes), from its files. Shared by the CLI and StillMade's Import folder. */
|
|
723
|
+
export function packageFromFolder(source:{ read(path:string):Promise<Uint8Array|undefined>; list(prefix:string):Promise<Record<string,Uint8Array>> }):Promise<{ pkg:BlockPackage; files:Record<string,Uint8Array>|null }>;
|
|
724
|
+
/** Whether a path inside a Block folder is part of the Block (node_modules, build output and notes are not). */
|
|
725
|
+
export function blockFolderPath(path:string):boolean;
|
|
726
|
+
/** The manifest file at the root of a Block folder. */
|
|
727
|
+
export const BLOCK_FOLDER_MANIFEST:"stillmade.block.json";
|
|
728
|
+
/** Size limits for Block folder source files, in bytes. */
|
|
729
|
+
export const BLOCK_FOLDER_LIMITS:Readonly<{jsonBytes:4000000;javascriptBytes:262144;viewScriptBytes:4194304;viewSourceBytes:262144}>;
|
|
@@ -166,3 +166,7 @@ export {pythonConnectionBlock} from './python-connection.js';
|
|
|
166
166
|
export {createConnectionClient,CONNECTION_API_OPERATIONS} from './connection-client.js';
|
|
167
167
|
|
|
168
168
|
export {configuredConnectionBlock} from './configured-connection.js';
|
|
169
|
+
|
|
170
|
+
export {summarizePermissions,permissionChanges,PERMISSION_LEVELS} from './permission-summary.js';
|
|
171
|
+
export {permissionUsage,fixPermissions} from './permission-usage.js';
|
|
172
|
+
export {packageFromFolder,blockFolderPath,BLOCK_FOLDER_MANIFEST,BLOCK_FOLDER_LIMITS} from './folder-package.js';
|
|
@@ -38,9 +38,11 @@ export async function admitLocalPackage(pkg,{timeoutMs=15000,files,moduleExecuti
|
|
|
38
38
|
// hosted calls, media and files; the digests are checked before anything runs.
|
|
39
39
|
// Loaded only for a module package: the Node executor needs worker threads, which the
|
|
40
40
|
// App's bundle (this file is part of it) must never pull in statically.
|
|
41
|
-
|
|
41
|
+
// A fixture that records its own mocks is answered from them.
|
|
42
|
+
const executors=moduleFiles?await (async()=>{const [{createNodeModuleExecutor},{createMockHost}]=await Promise.all([import('./module-node.js'),import('./mock-host.js')]);const module=createNodeModuleExecutor({files:moduleFiles,host:host||createMockHost({manifest:pkg.manifest}).host});return {module,moduleFor:fixture=>fixture?.mocks?createNodeModuleExecutor({files:moduleFiles,host:createMockHost({mocks:fixture.mocks,manifest:pkg.manifest}).host}):module};})():null;
|
|
43
|
+
const module=executors?.module;
|
|
42
44
|
const report=await admitPackage(pkg,{
|
|
43
|
-
test:content=>testPackage(content,{timeoutMs:remaining(),...(
|
|
45
|
+
test:content=>testPackage(content,{timeoutMs:remaining(),...(executors||{})}),
|
|
44
46
|
run:(content,input,options)=>{remaining();return runPackageAsync(content,input,{...options,signal:controller.signal,...(module?{module}:{})});},
|
|
45
47
|
});
|
|
46
48
|
remaining();
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
// in this run; results are saved to the runner's library, except audio.decode,
|
|
5
5
|
// which returns PCM samples for analysis.
|
|
6
6
|
import {assert,plain} from './contracts.js';
|
|
7
|
+
import {FINAL_RENDER_LIMITS} from './portable-media-contracts.js';
|
|
7
8
|
|
|
8
9
|
// decodeSamples bounds decoded PCM (all channels): 192 M samples is 768 MB of
|
|
9
10
|
// floats, about 100 minutes of 16 kHz stereo or 33 minutes of 48 kHz stereo.
|
|
10
11
|
export const MEDIA_TRANSFORM_LIMITS=Object.freeze({ranges:500,sources:50,durationSeconds:21600,decodeSamples:201326592,sampleRates:[8000,16000,22050,24000,32000,44100,48000],thumbnailWidth:4096});
|
|
11
|
-
export const MEDIA_TRANSFORM_OPERATIONS=Object.freeze(['audio.decode','audio.extract','audio.normalize','video.cut','video.concat','video.resize','image.thumbnail']);
|
|
12
|
+
export const MEDIA_TRANSFORM_OPERATIONS=Object.freeze(['audio.decode','audio.extract','audio.normalize','video.cut','video.concat','video.resize','image.thumbnail','timeline.render']);
|
|
13
|
+
// timeline.render (October 22, 2026, owner: exporting, captions, transitions and color are things a Block
|
|
14
|
+
// does, not Blocks): the Editor's own looks, transitions and caption animations, by name.
|
|
15
|
+
export const TIMELINE_LOOKS=Object.freeze(['None','Cold','Warm','Desaturated','High Contrast','Filmic','Teal and Orange']);
|
|
16
|
+
export const TIMELINE_TRANSITIONS=Object.freeze(['Cut','Fade','Cross dissolve','Shape wipe','Iris','Ink splat','Swirl','Whip left','Whip right','Whip up','Whip down','Whip pan','Slash','Shred','Morph','Dip to black','Dip to white','Push left','Push right','Push up','Push down','Zoom left','Zoom right','Zoom up','Zoom down']);
|
|
17
|
+
export const CAPTION_ANIMATIONS=Object.freeze(['none','fade','pop','scale_in_out','slide','typewriter','rise','word_pop','word_sequence','word_reveal']);
|
|
18
|
+
export const TIMELINE_ASPECTS=Object.freeze(['16:9','9:16','1:1','4:5']);
|
|
12
19
|
const ref=(value,kinds)=>plain(value)&&kinds.includes(value.kind)&&['assetId','versionId','url'].every(key=>typeof value[key]==='string'&&value[key].trim()&&value[key].length<=16384);
|
|
13
20
|
const settings=value=>value===undefined||plain(value)&&Object.keys(value).every(key=>['fps','resolution','quality'].includes(key))&&(value.fps===undefined||[24,30,60].includes(value.fps))&&(value.resolution===undefined||[720,1080,2160].includes(value.resolution))&&(value.quality===undefined||['low','medium','high'].includes(value.quality));
|
|
14
21
|
const fail=message=>assert(false,'MEDIA_TRANSFORM',message);
|
|
@@ -49,6 +56,7 @@ export function mediaTransformRequest(value){
|
|
|
49
56
|
if(!exact(value,['operation','source','settings'])||!ref(value.source,['video'])||!settings(value.settings)||!Object.keys(value.settings).length)fail('video.resize takes a saved video and the render settings to change (fps, resolution, quality)');
|
|
50
57
|
return {operation:value.operation,source:value.source,settings:{fps:30,resolution:1080,quality:'high',...value.settings}};
|
|
51
58
|
}
|
|
59
|
+
case 'timeline.render':return timelineRender(value);
|
|
52
60
|
case 'image.thumbnail':{
|
|
53
61
|
if(!exact(value,['operation','source'],['at','width'])||!ref(value.source,['video']))fail('image.thumbnail takes a saved video, an optional time and width');
|
|
54
62
|
const at=value.at??0,width=value.width??1280;
|
|
@@ -58,7 +66,7 @@ export function mediaTransformRequest(value){
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
/** The saved references a request reads. */
|
|
61
|
-
export const mediaTransformSources=request=>request.operation==='video.concat'?request.sources:[request.source];
|
|
69
|
+
export const mediaTransformSources=request=>request.operation==='video.concat'?request.sources:request.operation==='timeline.render'?[...request.clips,...request.audio].map(item=>item.source):[request.source];
|
|
62
70
|
|
|
63
71
|
/** Silent stretches in mono PCM: [{start, end}] seconds below thresholdDb for at least minSilence. */
|
|
64
72
|
export function silentRanges(samples,sampleRate,{thresholdDb=-40,minSilence=0.5,window=0.02}={}){
|
|
@@ -184,3 +192,35 @@ export function decodeWav(arrayBuffer) {
|
|
|
184
192
|
}
|
|
185
193
|
return { channels, sampleRate: fmt.sampleRate };
|
|
186
194
|
}
|
|
195
|
+
|
|
196
|
+
const seconds=value=>Number.isFinite(value)&&value>=0&&value<=MEDIA_TRANSFORM_LIMITS.durationSeconds;
|
|
197
|
+
const span=item=>seconds(item.start)&&Number.isFinite(item.duration)&&item.duration>0&&item.start+item.duration<=MEDIA_TRANSFORM_LIMITS.durationSeconds;
|
|
198
|
+
/** A Block's own timeline, rendered by the Editor's export (its plan, watermark and limits): video or only its audio. */
|
|
199
|
+
function timelineRender(value){
|
|
200
|
+
if(!exact(value,['operation','clips'],['kind','aspect','audio','captions','settings'])||!Array.isArray(value.clips)||!settings(value.settings))fail('timeline.render takes clips and optional kind, aspect, audio, captions and render settings');
|
|
201
|
+
const kind=value.kind??'video',aspect=value.aspect??'16:9',audio=value.audio??[],captions=value.captions??[];
|
|
202
|
+
if(!['video','audio'].includes(kind))fail('kind is video or audio');
|
|
203
|
+
if(!TIMELINE_ASPECTS.includes(aspect))fail(`aspect is one of ${TIMELINE_ASPECTS.join(', ')}`);
|
|
204
|
+
if(!Array.isArray(audio)||!Array.isArray(captions))fail('audio and captions are lists');
|
|
205
|
+
if(value.clips.length>FINAL_RENDER_LIMITS.clips||audio.length>FINAL_RENDER_LIMITS.audioItems||captions.length>FINAL_RENDER_LIMITS.overlays)fail(`At most ${FINAL_RENDER_LIMITS.clips} clips, ${FINAL_RENDER_LIMITS.audioItems} audio items and ${FINAL_RENDER_LIMITS.overlays} captions`);
|
|
206
|
+
if(kind==='video'?!value.clips.length:!value.clips.length&&!audio.length)fail(kind==='video'?'A video has at least one clip':'Audio needs at least one clip or audio item');
|
|
207
|
+
const clips=value.clips.map(clip=>{
|
|
208
|
+
if(!exact(clip,['source','start','duration'],['from','transition','look','mute'])||!ref(clip.source,['image','video'])||!span(clip)||clip.from!==undefined&&!seconds(clip.from)||clip.mute!==undefined&&typeof clip.mute!=='boolean')fail('A clip is {source: a saved image or video, start, duration, from?, transition?, look?, mute?} in seconds');
|
|
209
|
+
if(clip.transition!==undefined&&(!exact(clip.transition,['type'],['duration'])||!TIMELINE_TRANSITIONS.includes(clip.transition.type)||clip.transition.duration!==undefined&&!(Number.isFinite(clip.transition.duration)&&clip.transition.duration>=0.1&&clip.transition.duration<=3)))fail(`A transition is {type, duration?}: type one of ${TIMELINE_TRANSITIONS.join(', ')}, duration 0.1 to 3 seconds`);
|
|
210
|
+
if(clip.look!==undefined&&!TIMELINE_LOOKS.includes(clip.look))fail(`look is one of ${TIMELINE_LOOKS.join(', ')}`);
|
|
211
|
+
return {source:clip.source,start:clip.start,duration:clip.duration,from:clip.from??0,...(clip.transition?{transition:{type:clip.transition.type,duration:clip.transition.duration??0.5}}:{}),...(clip.look&&clip.look!=='None'?{look:clip.look}:{}),...(clip.mute?{mute:true}:{})};
|
|
212
|
+
});
|
|
213
|
+
const sounds=audio.map(item=>{
|
|
214
|
+
if(!exact(item,['source','start','duration'],['from','volume'])||!ref(item.source,['audio','video'])||!span(item)||item.from!==undefined&&!seconds(item.from)||item.volume!==undefined&&!(Number.isFinite(item.volume)&&item.volume>=0&&item.volume<=200))fail('An audio item is {source: a saved audio or video, start, duration, from?, volume? 0 to 200}');
|
|
215
|
+
return {source:item.source,start:item.start,duration:item.duration,from:item.from??0,volume:item.volume??100};
|
|
216
|
+
});
|
|
217
|
+
const lines=captions.map(line=>{
|
|
218
|
+
if(!exact(line,['text','start','end'],['words','position','highlight','animation'])||typeof line.text!=='string'||!line.text.trim()||line.text.length>500||!seconds(line.start)||!seconds(line.end)||line.end<=line.start)fail('A caption is {text up to 500 characters, start, end, words?, position?, highlight?, animation?}');
|
|
219
|
+
if(line.words!==undefined&&(!Array.isArray(line.words)||line.words.length>200||!line.words.every(word=>exact(word,['text','start','end'])&&typeof word.text==='string'&&word.text.length<=80&&seconds(word.start)&&seconds(word.end)&&word.end>=word.start)))fail('Caption words are up to 200 {text, start, end}, timed on the timeline');
|
|
220
|
+
if(line.position!==undefined&&!['bottom','center','top'].includes(line.position))fail('position is bottom, center or top');
|
|
221
|
+
if(line.highlight!==undefined&&!/^#[0-9a-fA-F]{6}$/.test(line.highlight))fail('highlight is a #rrggbb colour');
|
|
222
|
+
if(line.animation!==undefined&&!CAPTION_ANIMATIONS.includes(line.animation))fail(`animation is one of ${CAPTION_ANIMATIONS.join(', ')}`);
|
|
223
|
+
return {text:line.text,start:line.start,end:line.end,position:line.position??'bottom',...(line.words?{words:line.words.map(({text,start,end})=>({text,start,end}))}:{}),...(line.highlight?{highlight:line.highlight}:{}),...(line.animation?{animation:line.animation}:{})};
|
|
224
|
+
});
|
|
225
|
+
return {operation:'timeline.render',kind,aspect,clips,audio:sounds,captions:lines,settings:{fps:30,resolution:1080,quality:'high',...(value.settings||{})}};
|
|
226
|
+
}
|
|
@@ -13,25 +13,11 @@ import {mediaTransformRequest,decodeWav} from './media-transform.js';
|
|
|
13
13
|
import {mockPng,mockWav,mockMp4,MOCK_IMAGE_SIZE,MOCK_VIDEO_SIZE} from './mock-media.js';
|
|
14
14
|
export {mockPng,mockWav,mockMp4} from './mock-media.js';
|
|
15
15
|
|
|
16
|
-
export
|
|
16
|
+
export {MOCKS_FILE,validateMocks} from './mocks.js';
|
|
17
|
+
import {MOCKS_FILE,validateMocks} from './mocks.js';
|
|
17
18
|
export const MOCK_MEDIA_PREFIX='/api/media/stillmade-mock/';
|
|
18
|
-
const SECTIONS=['hosted','actions','network','connections','files','project','storage','transforms'];
|
|
19
19
|
const fail=(message,code='MOCK_HOST')=>assert(false,code,message);
|
|
20
20
|
|
|
21
|
-
/** Check a tests/mocks.json value. Every section is optional. */
|
|
22
|
-
export function validateMocks(value){
|
|
23
|
-
if(value===undefined||value===null)return {};
|
|
24
|
-
assert(plain(value)&&Object.keys(value).every(key=>SECTIONS.includes(key)),'MOCK_HOST',`${MOCKS_FILE} has the sections ${SECTIONS.join(', ')}`);
|
|
25
|
-
for(const key of ['hosted','actions','connections','files','storage','transforms'])if(Object.hasOwn(value,key))assert(plain(value[key]),'MOCK_HOST',`${MOCKS_FILE} ${key} is an object`);
|
|
26
|
-
if(Object.hasOwn(value,'network')){
|
|
27
|
-
assert(Array.isArray(value.network),'MOCK_HOST',`${MOCKS_FILE} network is a list of recorded responses`);
|
|
28
|
-
for(const entry of value.network)assert(plain(entry)&&typeof entry.url==='string'&&/^https:\/\//.test(entry.url)&&(entry.method===undefined||typeof entry.method==='string')&&(entry.status===undefined||Number.isInteger(entry.status)&&entry.status>=100&&entry.status<=599)&&[entry.json!==undefined,typeof entry.text==='string',typeof entry.base64==='string'].filter(Boolean).length<=1,'MOCK_HOST','Each recorded response has an https url, an optional method and status, and at most one of json, text or base64');
|
|
29
|
-
}
|
|
30
|
-
if(Object.hasOwn(value,'files'))for(const [name,path] of Object.entries(value.files))assert(typeof path==='string'&&path&&!path.startsWith('/')&&!path.split(/[\\/]/).includes('..'),'MOCK_HOST',`${MOCKS_FILE} files.${name} is a path inside the Block folder`);
|
|
31
|
-
if(Object.hasOwn(value,'project'))assert(plain(value.project),'MOCK_HOST',`${MOCKS_FILE} project is a project snapshot object`);
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
21
|
// A recorded entry may be one value or a list answered in order (the last repeats).
|
|
36
22
|
const pick=(entry,count)=>Array.isArray(entry)&&entry.length?structuredClone(entry[Math.min(count,entry.length-1)]):structuredClone(entry);
|
|
37
23
|
const words=text=>{let at=0;return text.split(/\s+/).filter(Boolean).map(word=>{const start=at;at=Number((at+0.4).toFixed(2));return {text:word,start,end:Number((start+0.35).toFixed(2))};});};
|
|
@@ -167,6 +153,7 @@ export function createMockHost({mocks:given={},readLocalFile,manifest}={}){
|
|
|
167
153
|
return {sampleRate:checked.sampleRate,duration:samples.length/checked.sampleRate,samples};
|
|
168
154
|
}
|
|
169
155
|
if(checked.operation==='image.thumbnail')return reference.image();
|
|
156
|
+
if(checked.operation==='timeline.render')return checked.kind==='audio'?reference.audio({sampleRate:44100}):reference.video();
|
|
170
157
|
if(checked.operation.startsWith('audio.'))return reference.audio({sampleRate:44100});
|
|
171
158
|
return reference.video();
|
|
172
159
|
},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The shape of recorded host answers (tests/mocks.json, or a module fixture's mocks), checked
|
|
2
|
+
// without loading the stand-in host itself.
|
|
3
|
+
import {assert,plain} from './contracts.js';
|
|
4
|
+
|
|
5
|
+
export const MOCKS_FILE='tests/mocks.json';
|
|
6
|
+
const SECTIONS=['hosted','actions','network','connections','files','project','storage','transforms'];
|
|
7
|
+
|
|
8
|
+
/** Check a tests/mocks.json value. Every section is optional. */
|
|
9
|
+
export function validateMocks(value){
|
|
10
|
+
if(value===undefined||value===null)return {};
|
|
11
|
+
assert(plain(value)&&Object.keys(value).every(key=>SECTIONS.includes(key)),'MOCK_HOST',`${MOCKS_FILE} has the sections ${SECTIONS.join(', ')}`);
|
|
12
|
+
for(const key of ['hosted','actions','connections','files','storage','transforms'])if(Object.hasOwn(value,key))assert(plain(value[key]),'MOCK_HOST',`${MOCKS_FILE} ${key} is an object`);
|
|
13
|
+
if(Object.hasOwn(value,'network')){
|
|
14
|
+
assert(Array.isArray(value.network),'MOCK_HOST',`${MOCKS_FILE} network is a list of recorded responses`);
|
|
15
|
+
for(const entry of value.network)assert(plain(entry)&&typeof entry.url==='string'&&/^https:\/\//.test(entry.url)&&(entry.method===undefined||typeof entry.method==='string')&&(entry.status===undefined||Number.isInteger(entry.status)&&entry.status>=100&&entry.status<=599)&&[entry.json!==undefined,typeof entry.text==='string',typeof entry.base64==='string'].filter(Boolean).length<=1,'MOCK_HOST','Each recorded response has an https url, an optional method and status, and at most one of json, text or base64');
|
|
16
|
+
}
|
|
17
|
+
if(Object.hasOwn(value,'files'))for(const [name,path] of Object.entries(value.files))assert(typeof path==='string'&&path&&!path.startsWith('/')&&!path.split(/[\\/]/).includes('..'),'MOCK_HOST',`${MOCKS_FILE} files.${name} is a path inside the Block folder`);
|
|
18
|
+
if(Object.hasOwn(value,'project'))assert(plain(value.project),'MOCK_HOST',`${MOCKS_FILE} project is a project snapshot object`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The host calls a module Block makes (`stillmade.files.read`, `hosted.run`...), answered by
|
|
2
|
+
// a host object. Shared by the SDK's local Node runner and the QuickJS runner StillMade uses for
|
|
3
|
+
// a built module's tests, so both check permissions and requests the same way.
|
|
4
|
+
import {networkDeclarations,networkRequest,connectionDeclarations,connectionCall} from './network.js';
|
|
5
|
+
import {storageHostHandlers} from './cloud-storage.js';
|
|
6
|
+
import {projectReadSnapshot} from './context.js';
|
|
7
|
+
|
|
8
|
+
/** {api: args => Promise} for one run of `pkg` against `host` (see createNodeModuleExecutor). */
|
|
9
|
+
export function moduleHostCalls(pkg,host,storage){
|
|
10
|
+
return {
|
|
11
|
+
'files.read':args=>host.readFile?host.readFile(args.ref):Promise.reject(Object.assign(new Error('files.read needs a host file reader in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
12
|
+
'files.write':args=>host.writeFile?host.writeFile(args.buffer,args.options):Promise.reject(Object.assign(new Error('files.write saves to the StillMade library; pass a host writer in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
13
|
+
'media.transform':args=>host.transformMedia?host.transformMedia(args.request):Promise.reject(Object.assign(new Error('Media transforms run in StillMade; pass a host transformMedia in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
14
|
+
'actions.run':args=>host.runAction?host.runAction(args.action):Promise.reject(Object.assign(new Error('Actions run in StillMade.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
15
|
+
'hosted.run':args=>host.runHosted?host.runHosted(args.operation,args.request):Promise.reject(Object.assign(new Error('Hosted operations run in StillMade.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
16
|
+
'connections.call':async args=>{const request=connectionCall(args.request,connectionDeclarations(pkg.manifest.permissions?.connections));return host.callConnection?host.callConnection(request):Promise.reject(Object.assign(new Error('Connections run through StillMade, which adds your account; pass a host callConnection in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
17
|
+
...storageHostHandlers(storage),
|
|
18
|
+
'project.read':async args=>{if(!host.project)throw Object.assign(new Error('project.read needs a project snapshot; pass host.project in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'});return projectReadSnapshot(pkg.manifest,host.project,args.fields);},
|
|
19
|
+
'assets.create':async args=>{if(!(pkg.manifest.permissions?.project||[]).includes('asset.create'))throw Object.assign(new Error('Declare asset.create to add project assets.'),{code:'MODULE_PERMISSION'});return host.createAsset?host.createAsset(args.file,args.options||{}):Promise.reject(Object.assign(new Error('Project assets are saved in StillMade; pass a host createAsset in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
20
|
+
'assets.appendVersion':async args=>{if(!(pkg.manifest.permissions?.project||[]).includes('asset.version.append'))throw Object.assign(new Error('Declare asset.version.append to add asset versions.'),{code:'MODULE_PERMISSION'});return host.appendAssetVersion?host.appendAssetVersion(args.asset,args.file):Promise.reject(Object.assign(new Error('Project assets are saved in StillMade; pass a host appendAssetVersion in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
21
|
+
'net.fetch':async args=>{const request=networkRequest(args.request,networkDeclarations(pkg.manifest.permissions?.network));return host.netFetch?host.netFetch(request):Promise.reject(Object.assign(new Error('Network calls run through StillMade, which adds your key; pass a host netFetch in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// contract a capability Block declares), so it is priced, quoted, approved,
|
|
4
4
|
// executed and checked exactly like one. The module Block must list the
|
|
5
5
|
// operation in `permissions.capabilities`.
|
|
6
|
-
import {assert,plain} from './contracts.js';
|
|
6
|
+
import {assert,plain,stableStringify} from './contracts.js';
|
|
7
7
|
import {defaultCapabilityExpectations} from './capabilities.js';
|
|
8
8
|
|
|
9
9
|
const text=(value,max)=>typeof value==='string'&&value.trim().length>0&&value.length<=max;
|
|
@@ -80,3 +80,24 @@ export function moduleHostedPackage(source,operation,request){
|
|
|
80
80
|
const manifest={schemaVersion:1,sdkVersion:'0.1.0',id:`${source.id}.hosted`,version:source.version,name:`${String(source.name||source.id).slice(0,100)} · ${operation}`,description:`A ${operation} call made by ${String(source.name||source.id).slice(0,200)}.`,kind:'task',runtime:'capability',entry:'src/capability.json',license:typeof source.license==='string'?source.license:'MIT',...(source.provenance?{provenance:source.provenance}:{}),inputs:shape.inputs,outputs:shape.outputs,permissions:{project:[],capabilities:[operation],network:[],filesystem:[],secrets:[]},ui:[]};
|
|
81
81
|
return {package:{manifest,capability:{schemaVersion:1,operation,...shape.capability},tests:[{name:'This call',input:shape.input,expectations:defaultCapabilityExpectations(manifest)}]},input:shape.input};
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a capability package is exactly the one moduleHostedPackage builds for a module Block's
|
|
86
|
+
* hosted call: rebuilt from its own fields, it must match in full. Such a call is StillMade's own
|
|
87
|
+
* shape with the module's words as input, so it needs no paid review before a project run; the
|
|
88
|
+
* person's press approves its price, as for StillMade's own Blocks (October 22, 2026, owner: no
|
|
89
|
+
* extra rules because a Block makes model calls). Anything else is not one.
|
|
90
|
+
*/
|
|
91
|
+
export function isModuleHostedPackage(content){
|
|
92
|
+
try{
|
|
93
|
+
const operation=content?.capability?.operation,manifest=content?.manifest,suffix=` · ${operation}`;
|
|
94
|
+
if(!plain(manifest)||typeof manifest.id!=='string'||!manifest.id.endsWith('.hosted')||!Object.hasOwn(SHAPES,operation)||typeof manifest.name!=='string'||!manifest.name.endsWith(suffix))return false;
|
|
95
|
+
const input=content.tests?.[0]?.input||{},request={};
|
|
96
|
+
// The request: the capability's own fields, each input reference replaced by the call's value.
|
|
97
|
+
for(const [key,value] of Object.entries(content.capability))if(!['schemaVersion','operation','output'].includes(key))request[key]=plain(value)&&typeof value.$input==='string'?input[value.$input]:value;
|
|
98
|
+
if(Number.isFinite(request.settings?.durationSec))request.durationSec=request.settings.durationSec;
|
|
99
|
+
if(Array.isArray(request.image)){request.images=request.image;delete request.image;}
|
|
100
|
+
const source={id:manifest.id.slice(0,-'.hosted'.length),version:manifest.version,name:manifest.name.slice(0,-suffix.length),license:manifest.license,...(manifest.provenance?{provenance:manifest.provenance}:{}),permissions:{capabilities:[operation]}};
|
|
101
|
+
return stableStringify(moduleHostedPackage(source,operation,request).package)===stableStringify(content);
|
|
102
|
+
}catch{return false;}
|
|
103
|
+
}
|
|
@@ -7,9 +7,10 @@ import {Worker} from 'node:worker_threads';
|
|
|
7
7
|
import {assert} from './contracts.js';
|
|
8
8
|
import {MODULE_WORKER_SOURCE} from './module-worker.js';
|
|
9
9
|
import {verifyModuleFile,moduleRunClock,MODULE_LIMITS} from './module-runtime.js';
|
|
10
|
-
import {networkDeclarations
|
|
11
|
-
import {createMemoryStorage
|
|
12
|
-
import {
|
|
10
|
+
import {networkDeclarations} from './network.js';
|
|
11
|
+
import {createMemoryStorage} from './cloud-storage.js';
|
|
12
|
+
import {moduleHostCalls} from './module-host-calls.js';
|
|
13
|
+
import {createModuleSockets} from './module-sockets.js';
|
|
13
14
|
|
|
14
15
|
// The worker global plays the browser Worker's role, so the same globals are removed.
|
|
15
16
|
const workerSource=`const {parentPort}=require('node:worker_threads');
|
|
@@ -23,7 +24,10 @@ ${MODULE_WORKER_SOURCE}(globalThis);`;
|
|
|
23
24
|
* runAction(action), runHosted(operation, request), storage, project,
|
|
24
25
|
* createAsset(file, options), appendAssetVersion(asset, file)}.
|
|
25
26
|
* `storage` defaults to an in-memory store kept for this executor's runs;
|
|
26
|
-
* `project` is the project snapshot `stillmade.project.read` reads from
|
|
27
|
+
* `project` is the project snapshot `stillmade.project.read` reads from;
|
|
28
|
+
* `netConnect(url, protocols, {open, message, close})` opens a live connection
|
|
29
|
+
* for `stillmade.net.connect` after the declared-origin check and returns
|
|
30
|
+
* {send(data), close(code, reason)} (without it, live connections are refused).
|
|
27
31
|
*/
|
|
28
32
|
export function createNodeModuleExecutor({files,host={},onProgress,onLog,now=()=>Date.now()}={}){
|
|
29
33
|
const storage=host.storage||createMemoryStorage();
|
|
@@ -35,20 +39,11 @@ export function createNodeModuleExecutor({files,host={},onProgress,onLog,now=()=
|
|
|
35
39
|
verified[path]=bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength);
|
|
36
40
|
}
|
|
37
41
|
const worker=new Worker(workerSource,{eval:true,resourceLimits:{maxOldGenerationSizeMb:1024}});
|
|
38
|
-
const logs=[],clock=moduleRunClock(now);let timer;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'actions.run':args=>host.runAction?host.runAction(args.action):Promise.reject(Object.assign(new Error('Actions run in StillMade.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
44
|
-
'hosted.run':args=>host.runHosted?host.runHosted(args.operation,args.request):Promise.reject(Object.assign(new Error('Hosted operations run in StillMade.'),{code:'MODULE_HOST_UNAVAILABLE'})),
|
|
45
|
-
'connections.call':async args=>{const request=connectionCall(args.request,connectionDeclarations(pkg.manifest.permissions?.connections));return host.callConnection?host.callConnection(request):Promise.reject(Object.assign(new Error('Connections run through StillMade, which adds your account; pass a host callConnection in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
46
|
-
...storageHostHandlers(storage),
|
|
47
|
-
'project.read':async args=>{if(!host.project)throw Object.assign(new Error('project.read needs a project snapshot; pass host.project in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'});return projectReadSnapshot(pkg.manifest,host.project,args.fields);},
|
|
48
|
-
'assets.create':async args=>{if(!(pkg.manifest.permissions?.project||[]).includes('asset.create'))throw Object.assign(new Error('Declare asset.create to add project assets.'),{code:'MODULE_PERMISSION'});return host.createAsset?host.createAsset(args.file,args.options||{}):Promise.reject(Object.assign(new Error('Project assets are saved in StillMade; pass a host createAsset in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
49
|
-
'assets.appendVersion':async args=>{if(!(pkg.manifest.permissions?.project||[]).includes('asset.version.append'))throw Object.assign(new Error('Declare asset.version.append to add asset versions.'),{code:'MODULE_PERMISSION'});return host.appendAssetVersion?host.appendAssetVersion(args.asset,args.file):Promise.reject(Object.assign(new Error('Project assets are saved in StillMade; pass a host appendAssetVersion in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
50
|
-
'net.fetch':async args=>{const request=networkRequest(args.request,networkDeclarations(pkg.manifest.permissions?.network));return host.netFetch?host.netFetch(request):Promise.reject(Object.assign(new Error('Network calls run through StillMade, which adds your key; pass a host netFetch in local runs.'),{code:'MODULE_HOST_UNAVAILABLE'}));},
|
|
51
|
-
};
|
|
42
|
+
const logs=[],clock=moduleRunClock(now);let timer,declared=[];
|
|
43
|
+
try{declared=networkDeclarations(pkg.manifest.permissions?.network);}catch{/* no valid origins */}
|
|
44
|
+
const sockets=createModuleSockets({declarations:declared,open:host.netConnect,post:(message,transfer)=>worker.postMessage(message,transfer)});
|
|
45
|
+
// The shared host calls (files, media, hosted operations, connections, storage, project, assets, net.fetch) plus live connections.
|
|
46
|
+
const handlers={...sockets.handlers,...moduleHostCalls(pkg,host,storage)};
|
|
52
47
|
try{
|
|
53
48
|
return await new Promise((resolve,reject)=>{
|
|
54
49
|
const stop=error=>{clearInterval(timer);reject(error);};
|
|
@@ -62,7 +57,7 @@ export function createNodeModuleExecutor({files,host={},onProgress,onLog,now=()=
|
|
|
62
57
|
else if(message.type==='progress'){clock.progress();onProgress?.(message);}
|
|
63
58
|
else if(message.type==='log'){if(logs.length<MODULE_LIMITS.logEntries)logs.push(message.message);onLog?.(message.message);}
|
|
64
59
|
else if(message.type==='call'){
|
|
65
|
-
const handler=handlers[message.api];clock.hostStarted();
|
|
60
|
+
const handler=Object.hasOwn(handlers,message.api)?handlers[message.api]:null;clock.hostStarted();
|
|
66
61
|
try{const result=await (handler?handler(message.args):Promise.reject(Object.assign(new Error('Unknown host call.'),{code:'MODULE_HOST'})));clock.hostFinished();worker.postMessage({type:'reply',id:message.id,result});}
|
|
67
62
|
catch(error){clock.hostFinished();worker.postMessage({type:'reply',id:message.id,error:{message:String(error?.message||error),code:error?.code||'MODULE_HOST'}});}
|
|
68
63
|
}
|
|
@@ -70,6 +65,6 @@ export function createNodeModuleExecutor({files,host={},onProgress,onLog,now=()=
|
|
|
70
65
|
else if(message.type==='error')stop(Object.assign(new Error(message.message),{code:message.cancelled?'CANCELLED':message.code||'MODULE_ERROR'}));
|
|
71
66
|
});
|
|
72
67
|
});
|
|
73
|
-
}finally{clearInterval(timer);await worker.terminate();}
|
|
68
|
+
}finally{clearInterval(timer);sockets.closeAll();await worker.terminate();}
|
|
74
69
|
};
|
|
75
70
|
}
|
|
@@ -23,7 +23,7 @@ export const MODULE_LIMITS=Object.freeze({
|
|
|
23
23
|
export const MODULE_MEDIA_TYPES=Object.freeze(['text/javascript','application/wasm','application/json','text/plain','image/png','image/jpeg','image/webp','font/woff2','application/octet-stream']);
|
|
24
24
|
// The host API an entry receives. Each name is a capability: a Block uses only
|
|
25
25
|
// what its manifest permissions allow, and the host checks every call.
|
|
26
|
-
export const MODULE_HOST_API=Object.freeze(['input','signal','progress','log','asset','files.read','files.write','media.transform','actions.run','hosted.run','connections.call','net.fetch','storage.info','storage.list','storage.get','storage.set','storage.remove','storage.putFile','storage.getFile','project.read','assets.create','assets.appendVersion']);
|
|
26
|
+
export const MODULE_HOST_API=Object.freeze(['input','signal','progress','log','asset','files.read','files.write','media.transform','actions.run','hosted.run','connections.call','net.fetch','net.connect','storage.info','storage.list','storage.get','storage.set','storage.remove','storage.putFile','storage.getFile','project.read','assets.create','assets.appendVersion']);
|
|
27
27
|
|
|
28
28
|
const SHA=/^[a-f0-9]{64}$/;
|
|
29
29
|
const PATH=/^(?!\/)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._\/-]+$/;
|
|
@@ -31,7 +31,10 @@ const fail=message=>assert(false,'MODULE_PACKAGE',message);
|
|
|
31
31
|
|
|
32
32
|
/** Check a module descriptor: `{schemaVersion:1, entry, files:{path:{sha256,bytes,mediaType}}}`. */
|
|
33
33
|
export function validateModuleDescriptor(module){
|
|
34
|
-
if(!plain(module)||Object.keys(module).some(key=>!['schemaVersion','entry','files'].includes(key)))fail('module is {schemaVersion:1, entry, files}');
|
|
34
|
+
if(!plain(module)||Object.keys(module).some(key=>!['schemaVersion','entry','files','threads'].includes(key)))fail('module is {schemaVersion:1, entry, files, threads?}');
|
|
35
|
+
// threads: true runs the module in a Worker that can share memory with Workers it starts
|
|
36
|
+
// (threaded WebAssembly), without WebCodecs, WebGPU or crypto.subtle. See MODULES.md.
|
|
37
|
+
if(module.threads!==undefined&&module.threads!==true)fail('module.threads is true or left out');
|
|
35
38
|
if(module.schemaVersion!==MODULE_RUNTIME_VERSION)fail('module.schemaVersion must be 1');
|
|
36
39
|
if(!plain(module.files))fail('module.files maps paths to {sha256, bytes, mediaType}');
|
|
37
40
|
const entries=Object.entries(module.files);
|
|
@@ -55,13 +58,13 @@ export async function moduleFileDigest(bytes){
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
/** Build a descriptor from in-memory files ({path: Uint8Array|string}), for the CLI and tests. */
|
|
58
|
-
export async function describeModuleFiles(entry,files,{mediaTypes={}}={}){
|
|
61
|
+
export async function describeModuleFiles(entry,files,{mediaTypes={},threads=false}={}){
|
|
59
62
|
const described={};
|
|
60
63
|
for(const [path,value] of Object.entries(files)){
|
|
61
64
|
const bytes=typeof value==='string'?new TextEncoder().encode(value):value instanceof Uint8Array?value:new Uint8Array(value);
|
|
62
65
|
described[path]={sha256:await moduleFileDigest(bytes),bytes:bytes.byteLength,mediaType:mediaTypes[path]||moduleMediaType(path)};
|
|
63
66
|
}
|
|
64
|
-
const module={schemaVersion:MODULE_RUNTIME_VERSION,entry,files:described};
|
|
67
|
+
const module={schemaVersion:MODULE_RUNTIME_VERSION,entry,files:described,...(threads===true?{threads:true}:{})};
|
|
65
68
|
validateModuleDescriptor(module);
|
|
66
69
|
return module;
|
|
67
70
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The trusted side of stillmade.net.connect for one module run. The Block's
|
|
2
|
+
// sandbox keeps `connect-src 'none'`: the host checks each URL against the
|
|
3
|
+
// declared origins, opens the WebSocket outside the sandbox through `open`, and
|
|
4
|
+
// relays messages both ways, with the count, size and byte limits of
|
|
5
|
+
// SOCKET_LIMITS. Every connection closes when the run ends.
|
|
6
|
+
//
|
|
7
|
+
// open(url, protocols, {open(protocol), message(data), close(code, reason, wasClean)})
|
|
8
|
+
// returns {send(data), close(code, reason)}; `data` is a string or an ArrayBuffer.
|
|
9
|
+
// post(message, transfer) delivers {type:'socket', id, event, ...} to the Worker.
|
|
10
|
+
import {SOCKET_LIMITS,socketRequest} from './network.js';
|
|
11
|
+
|
|
12
|
+
const fail=(message,code='NETWORK_SOCKET')=>{throw Object.assign(new Error(message),{code});};
|
|
13
|
+
const sizeOf=data=>typeof data==='string'?new TextEncoder().encode(data).byteLength:data instanceof ArrayBuffer?data.byteLength:-1;
|
|
14
|
+
|
|
15
|
+
export function createModuleSockets({declarations,open,post,limits=SOCKET_LIMITS}){
|
|
16
|
+
const sockets=new Map();let sequence=0,bytes=0,closed=false;
|
|
17
|
+
const count=size=>{bytes+=size;return bytes<=limits.runBytes;};
|
|
18
|
+
const connect=async({request})=>{
|
|
19
|
+
const checked=socketRequest(request,declarations);
|
|
20
|
+
if(closed)fail('This run has ended.');
|
|
21
|
+
if(!open)fail('Live connections are not available in this run.','MODULE_HOST_UNAVAILABLE');
|
|
22
|
+
if(sockets.size>=limits.sockets)fail(`A run keeps at most ${limits.sockets} live connections open.`,'NETWORK_SOCKET_LIMIT');
|
|
23
|
+
const id=++sequence;
|
|
24
|
+
return new Promise((resolve,reject)=>{
|
|
25
|
+
let opened=false;
|
|
26
|
+
const entry={socket:null};sockets.set(id,entry);
|
|
27
|
+
try{entry.socket=open(checked.url,checked.protocols,{
|
|
28
|
+
open:protocol=>{if(closed||opened)return;opened=true;resolve({id,protocol:String(protocol||'')});},
|
|
29
|
+
message:data=>{
|
|
30
|
+
if(closed||!sockets.has(id))return;
|
|
31
|
+
const size=sizeOf(data);
|
|
32
|
+
if(size<0)return;
|
|
33
|
+
if(size>limits.messageBytes){entry.socket.close(1000,'Message too big');return;}
|
|
34
|
+
if(!count(size)){entry.socket.close(1000,'Run byte limit reached');return;}
|
|
35
|
+
post({type:'socket',id,event:'message',data},data instanceof ArrayBuffer?[data]:[]);
|
|
36
|
+
},
|
|
37
|
+
close:(code,reason,wasClean)=>{
|
|
38
|
+
if(!sockets.delete(id))return;
|
|
39
|
+
if(!opened){reject(Object.assign(new Error(`The live connection to ${checked.origin} could not be opened.`),{code:'NETWORK_SOCKET'}));return;}
|
|
40
|
+
if(!closed)post({type:'socket',id,event:'close',code:Number(code)||1006,reason:String(reason||'').slice(0,123),wasClean:!!wasClean},[]);
|
|
41
|
+
},
|
|
42
|
+
});}catch(error){sockets.delete(id);reject(error);}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const command=async({id,op,data,code,reason})=>{
|
|
46
|
+
const entry=sockets.get(id);
|
|
47
|
+
if(!entry?.socket)fail('This live connection is closed.','NETWORK_SOCKET_CLOSED');
|
|
48
|
+
if(op==='send'){
|
|
49
|
+
const size=sizeOf(data);
|
|
50
|
+
if(size<0)fail('net.connect sends text or bytes.');
|
|
51
|
+
if(size>limits.messageBytes)fail(`A live connection message is at most ${limits.messageBytes/1048576} MiB.`,'NETWORK_SOCKET_LIMIT');
|
|
52
|
+
if(!count(size))fail(`A run sends and receives at most ${limits.runBytes/1048576} MiB over live connections.`,'NETWORK_SOCKET_LIMIT');
|
|
53
|
+
entry.socket.send(data);return {sent:size};
|
|
54
|
+
}
|
|
55
|
+
if(op==='close'){
|
|
56
|
+
const closeCode=code===undefined?1000:Number(code);
|
|
57
|
+
if(!(closeCode===1000||Number.isInteger(closeCode)&&closeCode>=3000&&closeCode<=4999))fail('Close a live connection with code 1000 or 3000 to 4999.');
|
|
58
|
+
const text=reason===undefined?'':String(reason);
|
|
59
|
+
if(new TextEncoder().encode(text).byteLength>123)fail('A close reason is at most 123 bytes.');
|
|
60
|
+
entry.socket.close(closeCode,text);return {closing:true};
|
|
61
|
+
}
|
|
62
|
+
fail('Unknown live connection operation.');
|
|
63
|
+
};
|
|
64
|
+
const closeAll=()=>{if(closed)return;closed=true;for(const entry of sockets.values())try{entry.socket?.close(1000,'The run ended');}catch{/* closed */}sockets.clear();};
|
|
65
|
+
return {handlers:{'net.connect':connect,'net.socket':command},closeAll,get open(){return sockets.size;}};
|
|
66
|
+
}
|