@maravilla-labs/platform 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @fileoverview Media transforms — async ffmpeg / image / OCR jobs.
3
+ *
4
+ * Mirrors the Rust `platform.media.transforms` surface. All mutating
5
+ * methods (transcode/thumbnail/resize/ocr) return a {@link JobHandle}
6
+ * whose `outputKey` is content-addressed via {@link keyFor} and known
7
+ * up front — clients can render placeholder UI for the derived asset
8
+ * before the worker even starts the job.
9
+ *
10
+ * The runtime injects the live JS object as `platform.media.transforms`
11
+ * (see `crates/runtime/src/ops/platform/js_bindings/platform_js.rs`).
12
+ * The static helper {@link keyFor} is exported separately and runs
13
+ * client-side — it must produce byte-for-byte identical output to the
14
+ * Rust `derive_key`. The shared golden-vector fixture
15
+ * `crates/platform/tests/derive_key_vectors.json` is consumed from both
16
+ * the Rust integration test (`crates/platform/tests/derive_key_golden.rs`)
17
+ * and the TS test (`packages/platform/tests/derive-key.test.ts`) to keep
18
+ * the two in lockstep.
19
+ */
20
+ /** Video container targets supported by v1. */
21
+ type VideoFormat = 'mp4' | 'webm';
22
+ /** Image output formats supported by v1. */
23
+ type ImageFormat = 'jpg' | 'png' | 'webp';
24
+ /** Lifecycle state of a transform job. */
25
+ type JobStatus = 'pending' | 'running' | 'complete' | 'failed';
26
+ /** Options for `transforms.transcode`. */
27
+ interface TranscodeOpts {
28
+ format: VideoFormat;
29
+ codec?: string;
30
+ max_width?: number;
31
+ max_height?: number;
32
+ audio_codec?: string;
33
+ bitrate_kbps?: number;
34
+ }
35
+ /** Options for `transforms.thumbnail` — extract a single video frame. */
36
+ interface ThumbnailOpts {
37
+ /** Offset into the source. Accepts `"00:00:01"`, `"1s"`, or a plain number-as-string. */
38
+ at: string;
39
+ width?: number;
40
+ height?: number;
41
+ /** Defaults to `"jpg"` server-side when omitted. */
42
+ format?: ImageFormat;
43
+ quality?: number;
44
+ }
45
+ /** Options for `transforms.resize`. */
46
+ interface ResizeOpts {
47
+ width?: number;
48
+ height?: number;
49
+ format: ImageFormat;
50
+ quality?: number;
51
+ strip_metadata?: boolean;
52
+ }
53
+ /** Options for `transforms.ocr`. */
54
+ interface OcrOpts {
55
+ /** Tesseract language code(s). Defaults to `"eng"` server-side when omitted. */
56
+ lang?: string;
57
+ }
58
+ /** Tagged union of all transform requests — matches the Rust `TransformSpec`. */
59
+ type TransformSpec = ({
60
+ kind: 'transcode';
61
+ } & TranscodeOpts) | ({
62
+ kind: 'thumbnail';
63
+ } & ThumbnailOpts) | ({
64
+ kind: 'resize';
65
+ } & ResizeOpts) | ({
66
+ kind: 'ocr';
67
+ } & OcrOpts);
68
+ /** Probe output. All fields optional — ffprobe doesn't fill every one for every input. */
69
+ interface MediaInfo {
70
+ duration_secs?: number;
71
+ width?: number;
72
+ height?: number;
73
+ video_codec?: string;
74
+ audio_codec?: string;
75
+ bitrate_bps?: number;
76
+ container?: string;
77
+ }
78
+ /** Returned by every mutating transforms method. */
79
+ interface JobHandle {
80
+ id: string;
81
+ src_key: string;
82
+ output_key: string;
83
+ status: JobStatus;
84
+ }
85
+ /** Returned by `transforms.job(id)`. */
86
+ interface JobStatusResponse {
87
+ id: string;
88
+ status: JobStatus;
89
+ }
90
+ interface TransformsService {
91
+ transcode(srcKey: string, opts: TranscodeOpts): Promise<JobHandle>;
92
+ thumbnail(srcKey: string, opts: ThumbnailOpts): Promise<JobHandle>;
93
+ resize(srcKey: string, opts: ResizeOpts): Promise<JobHandle>;
94
+ probe(srcKey: string): Promise<MediaInfo>;
95
+ ocr(srcKey: string, opts?: OcrOpts | null): Promise<JobHandle>;
96
+ job(id: string): Promise<JobStatusResponse>;
97
+ }
98
+ /**
99
+ * Compute the canonical derived-asset key for `(srcKey, spec)`.
100
+ *
101
+ * Shape: `__derived/<srcHash>/<variantHash>.<ext>` where each hash is
102
+ * the first 16 hex chars (8 bytes / 64 bits) of `SHA-256(...)`.
103
+ *
104
+ * **MUST** match Rust `platform::media::transforms::derive_key` byte-for-byte.
105
+ * Cross-language golden vectors live at
106
+ * `crates/platform/tests/derive_key_vectors.json`.
107
+ */
108
+ declare function keyFor(srcKey: string, spec: TransformSpec): string;
109
+ /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
110
+ declare const transforms: {
111
+ keyFor: typeof keyFor;
112
+ };
113
+ /**
114
+ * Per-pattern transforms declaration used inside the `transforms` block of
115
+ * `maravilla.config.ts`. Each field accepts a single opts object or an array.
116
+ *
117
+ * `variants` is sugar for `resize` arrays — convenient for the common
118
+ * "image → multiple resized renditions" case.
119
+ */
120
+ interface TransformsPatternSpec {
121
+ transcode?: TranscodeOpts | TranscodeOpts[];
122
+ thumbnail?: ThumbnailOpts | ThumbnailOpts[];
123
+ resize?: ResizeOpts | ResizeOpts[];
124
+ /** Sugar for `resize` arrays — same shape, same semantics. */
125
+ variants?: ResizeOpts[];
126
+ ocr?: OcrOpts | OcrOpts[];
127
+ }
128
+ /**
129
+ * Top-level `transforms` block. Keys are glob path patterns matched against
130
+ * the storage key of every uploaded object. The adapter compiles each entry
131
+ * into a synthetic `storage.put` event handler that fans out the declared
132
+ * transforms via `Promise.all`.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * import { defineConfig } from '@maravilla-labs/platform/config';
137
+ *
138
+ * export default defineConfig({
139
+ * transforms: {
140
+ * 'uploads/videos/**': {
141
+ * transcode: [{ format: 'mp4' }, { format: 'webm' }],
142
+ * thumbnail: { at: '1s', width: 640, format: 'jpg' },
143
+ * },
144
+ * 'uploads/photos/**': {
145
+ * variants: [
146
+ * { width: 1600, format: 'webp', quality: 85 },
147
+ * { width: 400, format: 'webp', quality: 80 },
148
+ * ],
149
+ * },
150
+ * },
151
+ * });
152
+ * ```
153
+ */
154
+ type TransformsConfig = Record<string, TransformsPatternSpec>;
155
+
156
+ export { type ImageFormat as I, type JobHandle as J, type MediaInfo as M, type OcrOpts as O, type ResizeOpts as R, type TransformsConfig as T, type VideoFormat as V, type TransformsPatternSpec as a, type JobStatus as b, type JobStatusResponse as c, type ThumbnailOpts as d, type TranscodeOpts as e, type TransformSpec as f, type TransformsService as g, keyFor as k, transforms as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Universal platform client for Maravilla runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,12 +27,17 @@
27
27
  "scripts": {
28
28
  "build": "tsup",
29
29
  "dev": "tsup --watch",
30
- "typecheck": "tsc --noEmit"
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run"
32
+ },
33
+ "dependencies": {
34
+ "@noble/hashes": "^1.5.0"
31
35
  },
32
36
  "devDependencies": {
33
37
  "@types/node": "^22.10.6",
34
38
  "tsup": "^8.5.0",
35
- "typescript": "^5.9.2"
39
+ "typescript": "^5.9.2",
40
+ "vitest": "^1.6.0"
36
41
  },
37
42
  "peerDependencies": {
38
43
  "livekit-client": "^2.18.1"
package/src/config.ts CHANGED
@@ -38,6 +38,9 @@
38
38
  */
39
39
  export type SecretRef = string | { env: string };
40
40
 
41
+ import type { TransformsConfig } from './transforms.js';
42
+ export type { TransformsConfig, TransformsPatternSpec } from './transforms.js';
43
+
41
44
  // ── Resources + policies ──
42
45
 
43
46
  export interface ResourceDefinition {
@@ -198,6 +201,13 @@ export interface MaravillaConfig {
198
201
  auth?: AuthConfigBlock;
199
202
  /** Declarative database indexes (regular + vector). Reconciled upsert-only on deploy. */
200
203
  database?: DatabaseConfigBlock;
204
+ /**
205
+ * Declarative media transforms — each entry compiles into a synthetic
206
+ * `storage.put` event handler that fans out the declared
207
+ * `platform.media.transforms.*` calls (via `Promise.all`) for every
208
+ * matching upload. See {@link TransformsConfig}.
209
+ */
210
+ transforms?: TransformsConfig;
201
211
  }
202
212
 
203
213
  // ── Database block ──
package/src/events.ts CHANGED
@@ -33,6 +33,7 @@ export type EventTrigger =
33
33
  | EventTriggerQueue
34
34
  | EventTriggerChannel
35
35
  | EventTriggerDeploy
36
+ | EventTriggerStorage
36
37
  | EventTriggerRen;
37
38
 
38
39
  export interface EventTriggerKv {
@@ -84,6 +85,20 @@ export interface EventTriggerDeploy {
84
85
  phase: 'ready' | 'draining' | 'stopped';
85
86
  }
86
87
 
88
+ /**
89
+ * Object storage trigger — fires when an object is created or removed
90
+ * under the matching `keyPattern`. Used by both hand-written `onStorage`
91
+ * handlers and by the synthesized handlers that the adapter compiles
92
+ * from a `transforms` block in `maravilla.config.ts`.
93
+ */
94
+ export interface EventTriggerStorage {
95
+ kind: 'storage';
96
+ /** Glob-style storage key pattern (e.g. `"uploads/videos/**"`). Omit to match all objects. */
97
+ keyPattern?: string;
98
+ /** Operation filter; omit to match both `put` and `delete`. */
99
+ op?: 'put' | 'delete';
100
+ }
101
+
87
102
  export interface EventTriggerRen {
88
103
  kind: 'ren';
89
104
  match: { r?: string; t?: string; ns?: string };
@@ -157,6 +172,21 @@ export interface DeployEvent {
157
172
  ts: number;
158
173
  }
159
174
 
175
+ /**
176
+ * Storage event payload — emitted by the dev-server / runtime when an
177
+ * object is put or deleted. The synthesized transforms handlers consume
178
+ * the `key` field to look up the source object.
179
+ */
180
+ export interface StorageEvent {
181
+ op: 'put' | 'delete';
182
+ key: string;
183
+ /** Present on `put` — content type from the upload metadata, when known. */
184
+ contentType?: string;
185
+ /** Present on `put` — size in bytes, when known. */
186
+ size?: number;
187
+ ts: number;
188
+ }
189
+
160
190
  // ============ Handler context ============
161
191
 
162
192
  export interface EventCtx {
@@ -264,6 +294,28 @@ export function onDeploy(
264
294
  return register({ kind: 'deploy', phase }, handler);
265
295
  }
266
296
 
297
+ /**
298
+ * React to object-storage `put` / `delete` events. `keyPattern` is a
299
+ * glob (e.g. `"uploads/videos/**"`). Omit `op` to match both put and
300
+ * delete; omit `keyPattern` to match every object in the tenant's
301
+ * bucket.
302
+ *
303
+ * ```ts
304
+ * export const onUpload = onStorage(
305
+ * { keyPattern: 'uploads/photos/**', op: 'put' },
306
+ * async (event, ctx) => {
307
+ * await ctx.platform.media.transforms.resize(event.key, { width: 1600, format: 'webp' });
308
+ * },
309
+ * );
310
+ * ```
311
+ */
312
+ export function onStorage(
313
+ config: Omit<EventTriggerStorage, 'kind'>,
314
+ handler: (event: StorageEvent, ctx: EventCtx) => unknown | Promise<unknown>,
315
+ ): RegisteredHandler<StorageEvent> {
316
+ return register({ kind: 'storage', ...config }, handler);
317
+ }
318
+
267
319
  /** Escape hatch for arbitrary `RenEvent` matches. */
268
320
  export function defineEvent(
269
321
  config: Omit<EventTriggerRen, 'kind'>,
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export * from './realtime.js';
52
52
  export * from './media.js';
53
53
  export * from './media-room.js';
54
54
  export * from './push.js';
55
+ export * from './transforms.js';
55
56
 
56
57
  /**
57
58
  * Global platform instance injected by Maravilla runtime or development tools.
@@ -1,4 +1,4 @@
1
- import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions, PolicyService, VectorIndexSpec, VectorIndexDescriptor, VectorQueryWithFilter, VectorSearchHit, IndexSpec, IndexDescriptor } from './types.js';
1
+ import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions, PolicyService, VectorIndexSpec, VectorIndexDescriptor, VectorQueryWithFilter, VectorSearchHit, IndexSpec, IndexDescriptor, Workflows, WorkflowHandle, WorkflowRun, WorkflowStepRecord } from './types.js';
2
2
  import { RemoteMediaService } from './media.js';
3
3
 
4
4
  /**
@@ -396,6 +396,21 @@ class RemoteStorage implements Storage {
396
396
  if (response.status === 404) return null;
397
397
  return response.json();
398
398
  }
399
+
400
+ async confirm(key: string): Promise<void> {
401
+ // Mirrors the dev-server route registered in
402
+ // `crates/dev-server/src/router.rs`:
403
+ // POST /api/storage/confirm body: { key: "..." }
404
+ // The handler verifies the object actually landed in the backing store
405
+ // and publishes the synthesized `storage.put` RenEvent that any
406
+ // registered handlers (including those synthesized from a `transforms`
407
+ // config block) react to.
408
+ await this.fetch(`${this.baseUrl}/api/storage/confirm`, {
409
+ method: 'POST',
410
+ headers: { 'content-type': 'application/json' },
411
+ body: JSON.stringify({ key }),
412
+ });
413
+ }
399
414
  }
400
415
 
401
416
  /**
@@ -628,6 +643,127 @@ class RemotePolicyService implements PolicyService {
628
643
  }
629
644
  }
630
645
 
646
+ /**
647
+ * Remote Workflows service — HTTP adaptor over the dev-server's
648
+ * `/api/workflows/*` routes. The runtime (isolate) path uses `Deno.core.ops`
649
+ * instead; both produce handles with the same shape.
650
+ *
651
+ * @internal
652
+ */
653
+ class RemoteWorkflows implements Workflows {
654
+ constructor(
655
+ private baseUrl: string,
656
+ private headers: Record<string, string>,
657
+ ) {}
658
+
659
+ private async request(path: string, options: RequestInit = {}): Promise<Response> {
660
+ const response = await fetch(`${this.baseUrl}${path}`, {
661
+ ...options,
662
+ headers: { ...this.headers, ...options.headers },
663
+ });
664
+ if (!response.ok && response.status !== 404) {
665
+ const error = await response.text();
666
+ throw new Error(`Platform API error: ${response.status} - ${error}`);
667
+ }
668
+ return response;
669
+ }
670
+
671
+ async start(workflowId: string, input: unknown = {}): Promise<WorkflowHandle> {
672
+ if (typeof workflowId !== 'string' || !workflowId) {
673
+ throw new Error('workflows.start: workflowId must be a non-empty string');
674
+ }
675
+ const res = await this.request(
676
+ `/api/workflows/${encodeURIComponent(workflowId)}/start`,
677
+ { method: 'POST', body: JSON.stringify(input ?? {}) },
678
+ );
679
+ const { runId } = (await res.json()) as { runId: string };
680
+ return makeRemoteWorkflowHandle(this.baseUrl, this.headers, runId);
681
+ }
682
+
683
+ handle(runId: string): WorkflowHandle {
684
+ if (typeof runId !== 'string' || !runId) {
685
+ throw new Error('workflows.handle: runId must be a non-empty string');
686
+ }
687
+ return makeRemoteWorkflowHandle(this.baseUrl, this.headers, runId);
688
+ }
689
+
690
+ async sendEvent(eventType: string, payload: unknown = null): Promise<number> {
691
+ if (typeof eventType !== 'string' || !eventType) {
692
+ throw new Error('workflows.sendEvent: eventType must be a non-empty string');
693
+ }
694
+ const res = await this.request(`/api/workflows/signal`, {
695
+ method: 'POST',
696
+ body: JSON.stringify({ eventType, payload }),
697
+ });
698
+ const data = (await res.json()) as { count: number };
699
+ return data.count;
700
+ }
701
+ }
702
+
703
+ function makeRemoteWorkflowHandle(
704
+ baseUrl: string,
705
+ headers: Record<string, string>,
706
+ runId: string,
707
+ ): WorkflowHandle {
708
+ const doFetch = async (path: string, init: RequestInit = {}): Promise<Response> => {
709
+ const res = await fetch(`${baseUrl}${path}`, {
710
+ ...init,
711
+ headers: { ...headers, ...init.headers },
712
+ });
713
+ if (!res.ok && res.status !== 404) {
714
+ throw new Error(`Platform API error: ${res.status} - ${await res.text()}`);
715
+ }
716
+ return res;
717
+ };
718
+
719
+ const handle: WorkflowHandle = {
720
+ runId,
721
+ async status(): Promise<WorkflowRun | null> {
722
+ const res = await doFetch(`/api/workflows/runs/${encodeURIComponent(runId)}`);
723
+ if (res.status === 404) return null;
724
+ return (await res.json()) as WorkflowRun;
725
+ },
726
+ async history(): Promise<WorkflowStepRecord[]> {
727
+ const res = await doFetch(
728
+ `/api/workflows/runs/${encodeURIComponent(runId)}/history`,
729
+ );
730
+ return (await res.json()) as WorkflowStepRecord[];
731
+ },
732
+ async cancel(): Promise<boolean> {
733
+ const res = await doFetch(
734
+ `/api/workflows/runs/${encodeURIComponent(runId)}/cancel`,
735
+ { method: 'POST' },
736
+ );
737
+ const data = (await res.json()) as { cancelled: boolean };
738
+ return data.cancelled;
739
+ },
740
+ async result(options?: { timeoutMs?: number }): Promise<unknown> {
741
+ const timeoutMs = options?.timeoutMs;
742
+ const deadline =
743
+ typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
744
+ ? Date.now() + timeoutMs
745
+ : null;
746
+ let delay = 100;
747
+ while (true) {
748
+ const run = await handle.status();
749
+ if (!run) throw new Error(`workflow run not found: ${runId}`);
750
+ if (run.status === 'completed') return run.output;
751
+ if (run.status === 'failed' || run.status === 'cancelled') {
752
+ const err = new Error(`workflow ${run.status}`) as Error & { run: WorkflowRun };
753
+ err.run = run;
754
+ throw err;
755
+ }
756
+ if (deadline !== null && Date.now() >= deadline) {
757
+ throw new Error('workflow result timeout');
758
+ }
759
+ await new Promise((r) => setTimeout(r, delay));
760
+ delay = Math.min(delay * 2, 5000);
761
+ }
762
+ },
763
+ };
764
+ return handle;
765
+ }
766
+
631
767
  /**
632
768
  * Create a remote platform client for development environments.
633
769
  *
@@ -671,6 +807,7 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
671
807
  const realtime = new RemoteRealtimeService(baseUrl, headers);
672
808
  const auth = new RemoteAuthService(baseUrl, headers);
673
809
  const policy = new RemotePolicyService();
810
+ const workflows = new RemoteWorkflows(baseUrl, headers);
674
811
 
675
812
  return {
676
813
  env: {
@@ -682,5 +819,6 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
682
819
  realtime,
683
820
  auth,
684
821
  policy,
822
+ workflows,
685
823
  };
686
824
  }
package/src/ren.ts CHANGED
@@ -122,6 +122,12 @@ export class RenClient {
122
122
  'presence.join',
123
123
  'presence.leave',
124
124
  'presence.update',
125
+ // Media transform lifecycle (emitted by media-transforms-worker)
126
+ 'transform.queued',
127
+ 'transform.started',
128
+ 'transform.progress',
129
+ 'transform.complete',
130
+ 'transform.failed',
125
131
  // Meta events
126
132
  'ren.meta'
127
133
  ];