@vxil/cli 0.13.2 → 0.14.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,73 @@
|
|
|
1
|
+
import { type Static } from '@sinclair/typebox';
|
|
2
|
+
/** extension → the exact Content-Type served for it. */
|
|
3
|
+
export declare const PUBLIC_ASSET_TYPES: Readonly<Record<string, string>>;
|
|
4
|
+
/** The extension a stored content type publishes under, or null when the type
|
|
5
|
+
* may not be published (html, svg, pdf, zip, octet-stream, …). */
|
|
6
|
+
export declare function publicExtForContentType(contentType: string | null | undefined): string | null;
|
|
7
|
+
/** Raster image extensions a variant preset may transform. */
|
|
8
|
+
export declare const VARIANT_SOURCE_EXTS: ReadonlySet<string>;
|
|
9
|
+
/** Video/audio types served `inline` (playable) by shared links and the public
|
|
10
|
+
* host — media formats a browser plays in a player, never as a document. */
|
|
11
|
+
export declare const INLINE_MEDIA_TYPES: ReadonlySet<string>;
|
|
12
|
+
/** The public-asset URL path shape: `/<tenant_id>/<sha256>.<ext>` with an
|
|
13
|
+
* optional `/v/<preset>` variant suffix. The tenant is bound from THIS path
|
|
14
|
+
* (never a Host header). */
|
|
15
|
+
export declare const PUBLIC_ASSET_PATH_RE: RegExp;
|
|
16
|
+
/** The object key in the public bucket: `<tenant_id>/<sha256>.<ext>` —
|
|
17
|
+
* tenant-prefixed (no cross-tenant dedupe, so no hash-existence oracle across
|
|
18
|
+
* tenants) and content-addressed (immutable: a URL never changes meaning). */
|
|
19
|
+
export declare function publicAssetKey(tenantId: string, sha256: string, ext: string): string;
|
|
20
|
+
/** Most variant presets one tenant may declare (the schema bound; the plan
|
|
21
|
+
* ceiling in control-plane plans.ts may be lower). */
|
|
22
|
+
export declare const MAX_VARIANT_PRESETS = 8;
|
|
23
|
+
/** Most CORS origins one tenant may declare. */
|
|
24
|
+
export declare const MAX_CORS_ORIGINS = 20;
|
|
25
|
+
/** Most object ids one bulk publish accepts (the batch download-urls bound). */
|
|
26
|
+
export declare const MAX_BULK_PUBLISH = 100;
|
|
27
|
+
/** Largest object that may be published (bytes): 512 MiB — the largest
|
|
28
|
+
* object the public host's edge cache holds, so every published object
|
|
29
|
+
* (and every Range of it) is served from the cache after its first read.
|
|
30
|
+
* Bigger media belongs on a streaming/transcoding vendor, not a
|
|
31
|
+
* cache-forever host. */
|
|
32
|
+
export declare const MAX_PUBLISH_OBJECT_BYTES: number;
|
|
33
|
+
/** Most bytes ONE bulk publish call may stream (hashing an object without a
|
|
34
|
+
* recorded checksum reads it once, copying reads it again). Ids past the
|
|
35
|
+
* budget land in `errors[]` as `batch_budget_exceeded` — publish them in a
|
|
36
|
+
* further call. */
|
|
37
|
+
export declare const MAX_BULK_PUBLISH_BYTES: number;
|
|
38
|
+
/** Largest source image a variant preset transforms (bytes). */
|
|
39
|
+
export declare const MAX_VARIANT_SOURCE_BYTES: number;
|
|
40
|
+
/** A CORS origin: `*`, an https origin, or a localhost http origin (dev). No
|
|
41
|
+
* path, no trailing slash, no wildcard sub-domains. */
|
|
42
|
+
export declare const CORS_ORIGIN_PATTERN = "^(\\*|https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
|
|
43
|
+
/** A preset name: lower-case, URL-safe, ≤ 32 chars (it is a path segment). */
|
|
44
|
+
export declare const VARIANT_PRESET_NAME_PATTERN = "^[a-z0-9][a-z0-9_-]{0,31}$";
|
|
45
|
+
export declare const VariantPresetSchema: import("@sinclair/typebox").TObject<{
|
|
46
|
+
w: import("@sinclair/typebox").TInteger;
|
|
47
|
+
h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
48
|
+
fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
|
|
49
|
+
fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
|
|
50
|
+
q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
51
|
+
}>;
|
|
52
|
+
export type VariantPreset = Static<typeof VariantPresetSchema>;
|
|
53
|
+
/** `files.publicAssets` — ONE optional bag (one leaf against the 15-leaf cap).
|
|
54
|
+
* `enabled` is the tenant's own kill switch: false ⇒ publish refuses and the
|
|
55
|
+
* public host answers 404 for every one of the tenant's assets. */
|
|
56
|
+
export declare const PublicAssetsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
57
|
+
enabled: import("@sinclair/typebox").TBoolean;
|
|
58
|
+
corsOrigins: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
59
|
+
variants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
60
|
+
w: import("@sinclair/typebox").TInteger;
|
|
61
|
+
h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
62
|
+
fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
|
|
63
|
+
fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
|
|
64
|
+
q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
65
|
+
}>>>;
|
|
66
|
+
}>;
|
|
67
|
+
export type PublicAssetsConfig = Static<typeof PublicAssetsConfigSchema>;
|
|
68
|
+
/** The MIME type a variant `fmt` outputs. */
|
|
69
|
+
export declare function variantOutputType(fmt: VariantPreset['fmt']): 'image/webp' | 'image/avif' | 'image/jpeg' | 'image/png';
|
|
70
|
+
/** A stable short fingerprint of a preset definition — part of the variant
|
|
71
|
+
* cache key, so editing a preset never serves the old rendition. FNV-1a over
|
|
72
|
+
* the canonical field order (no crypto needed: it only has to change). */
|
|
73
|
+
export declare function presetFingerprint(p: VariantPreset): string;
|
|
@@ -3,6 +3,7 @@ export * from './_vxil-feature-configs-hooks.js';
|
|
|
3
3
|
export * from './_vxil-feature-configs-readmodels.js';
|
|
4
4
|
export * from './_vxil-feature-configs-apiState.js';
|
|
5
5
|
export * from './_vxil-feature-configs-canonicalJson.js';
|
|
6
|
+
export * from './_vxil-feature-configs-publicAssets.js';
|
|
6
7
|
export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
|
|
7
8
|
/** A function binding's `retry.maxAttempts` ceiling, and the
|
|
8
9
|
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
@@ -378,6 +379,17 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
378
379
|
asyncOverJobs: import("@sinclair/typebox").TBoolean;
|
|
379
380
|
boundingBoxes: import("@sinclair/typebox").TBoolean;
|
|
380
381
|
}>>;
|
|
382
|
+
publicAssets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
383
|
+
enabled: import("@sinclair/typebox").TBoolean;
|
|
384
|
+
corsOrigins: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
385
|
+
variants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
386
|
+
w: import("@sinclair/typebox").TInteger;
|
|
387
|
+
h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
388
|
+
fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
|
|
389
|
+
fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
|
|
390
|
+
q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
391
|
+
}>>>;
|
|
392
|
+
}>>;
|
|
381
393
|
}>;
|
|
382
394
|
export type FilesConfig = Static<typeof FilesConfigSchema>;
|
|
383
395
|
export declare const DeclaredWebhookSubscriptionSchema: import("@sinclair/typebox").TObject<{
|
package/dist/vxil.js
CHANGED
|
@@ -2861,6 +2861,65 @@ function formatApiStateDetail(s) {
|
|
|
2861
2861
|
return lines;
|
|
2862
2862
|
}
|
|
2863
2863
|
|
|
2864
|
+
// ../feature-configs/src/publicAssets.ts
|
|
2865
|
+
var PUBLIC_ASSET_TYPES = {
|
|
2866
|
+
png: "image/png",
|
|
2867
|
+
jpg: "image/jpeg",
|
|
2868
|
+
webp: "image/webp",
|
|
2869
|
+
avif: "image/avif",
|
|
2870
|
+
gif: "image/gif",
|
|
2871
|
+
mp4: "video/mp4",
|
|
2872
|
+
webm: "video/webm",
|
|
2873
|
+
mp3: "audio/mpeg",
|
|
2874
|
+
m4a: "audio/mp4",
|
|
2875
|
+
ogg: "audio/ogg",
|
|
2876
|
+
woff2: "font/woff2",
|
|
2877
|
+
woff: "font/woff",
|
|
2878
|
+
json: "application/json"
|
|
2879
|
+
};
|
|
2880
|
+
var TYPE_TO_EXT = {
|
|
2881
|
+
...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
|
|
2882
|
+
"image/jpg": "jpg",
|
|
2883
|
+
"image/pjpeg": "jpg",
|
|
2884
|
+
"audio/mp3": "mp3",
|
|
2885
|
+
"audio/x-m4a": "m4a",
|
|
2886
|
+
"audio/m4a": "m4a",
|
|
2887
|
+
"application/font-woff": "woff",
|
|
2888
|
+
"application/font-woff2": "woff2"
|
|
2889
|
+
};
|
|
2890
|
+
var MAX_VARIANT_PRESETS = 8;
|
|
2891
|
+
var MAX_CORS_ORIGINS = 20;
|
|
2892
|
+
var MAX_PUBLISH_OBJECT_BYTES = 512 * 1024 * 1024;
|
|
2893
|
+
var MAX_BULK_PUBLISH_BYTES = 2 * 1024 * 1024 * 1024;
|
|
2894
|
+
var MAX_VARIANT_SOURCE_BYTES = 25 * 1024 * 1024;
|
|
2895
|
+
var CORS_ORIGIN_PATTERN = "^(\\*|https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
|
|
2896
|
+
var VARIANT_PRESET_NAME_PATTERN = "^[a-z0-9][a-z0-9_-]{0,31}$";
|
|
2897
|
+
var VariantPresetSchema = Type.Object({
|
|
2898
|
+
w: Type.Integer({ minimum: 1, maximum: 4096 }),
|
|
2899
|
+
h: Type.Optional(Type.Integer({ minimum: 1, maximum: 4096 })),
|
|
2900
|
+
fit: Type.Optional(Type.Union([
|
|
2901
|
+
Type.Literal("scale-down"),
|
|
2902
|
+
Type.Literal("contain"),
|
|
2903
|
+
Type.Literal("cover"),
|
|
2904
|
+
Type.Literal("crop"),
|
|
2905
|
+
Type.Literal("pad")
|
|
2906
|
+
])),
|
|
2907
|
+
fmt: Type.Union([Type.Literal("webp"), Type.Literal("avif"), Type.Literal("jpeg"), Type.Literal("png")]),
|
|
2908
|
+
q: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 }))
|
|
2909
|
+
});
|
|
2910
|
+
var PublicAssetsConfigSchema = Type.Object({
|
|
2911
|
+
enabled: Type.Boolean({ default: false }),
|
|
2912
|
+
corsOrigins: Type.Array(Type.String({ pattern: CORS_ORIGIN_PATTERN, maxLength: 253 }), {
|
|
2913
|
+
default: [],
|
|
2914
|
+
maxItems: MAX_CORS_ORIGINS
|
|
2915
|
+
}),
|
|
2916
|
+
variants: Type.Optional(Type.Record(
|
|
2917
|
+
Type.String({ pattern: VARIANT_PRESET_NAME_PATTERN }),
|
|
2918
|
+
VariantPresetSchema,
|
|
2919
|
+
{ maxProperties: MAX_VARIANT_PRESETS }
|
|
2920
|
+
))
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2864
2923
|
// ../feature-configs/src/canonicalJson.ts
|
|
2865
2924
|
function canonicalJson(v) {
|
|
2866
2925
|
if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? "null").join(",")}]`;
|
|
@@ -3415,7 +3474,12 @@ var FilesConfigSchema = Type.Object({
|
|
|
3415
3474
|
asyncOverJobs: Type.Boolean({ default: true }),
|
|
3416
3475
|
// large/multi-page → jobs
|
|
3417
3476
|
boundingBoxes: Type.Boolean({ default: false })
|
|
3418
|
-
}))
|
|
3477
|
+
})),
|
|
3478
|
+
// Public asset delivery (2026-10-03, guide ch. 6 files): publish
|
|
3479
|
+
// an object to the cache-forever public host (POST /v1/files/{id}/publish).
|
|
3480
|
+
// ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
|
|
3481
|
+
// ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
|
|
3482
|
+
publicAssets: Type.Optional(PublicAssetsConfigSchema)
|
|
3419
3483
|
});
|
|
3420
3484
|
var DeclaredWebhookSubscriptionSchema = Type.Object({
|
|
3421
3485
|
target_url: Type.String({ minLength: 9, maxLength: 2e3 }),
|
|
@@ -4456,9 +4520,9 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
4456
4520
|
return bindings;
|
|
4457
4521
|
}
|
|
4458
4522
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
4459
|
-
var VXIL_CONFIG_PKG_VERSION = "0.
|
|
4460
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
4461
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
4523
|
+
var VXIL_CONFIG_PKG_VERSION = "0.9.0";
|
|
4524
|
+
var VXIL_SDK_PKG_VERSION = "0.14.1";
|
|
4525
|
+
var VXIL_CLI_PKG_VERSION = "0.14.1";
|
|
4462
4526
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
4463
4527
|
const file = resolve(cwd, "package.json");
|
|
4464
4528
|
const wanted = {
|
|
@@ -11369,7 +11433,7 @@ var TOOLS = [
|
|
|
11369
11433
|
{
|
|
11370
11434
|
name: "jobs_enqueue",
|
|
11371
11435
|
feature: "jobs",
|
|
11372
|
-
description: "Enqueue a background job: Vxil POSTs a signed callback to your https target_url with retries/backoff until 2xx. Verify X-Vxil-Jobs-Signature against jobs_get_signing_secret. idempotency_key dedups for 24h. deliver_after (ISO) / delay_seconds (\u2264 30 d, at most one) defer the first delivery; > 12 h returns state 'delayed'.",
|
|
11436
|
+
description: "Enqueue a background job: Vxil POSTs a signed callback to your https target_url with retries/backoff until 2xx. Verify X-Vxil-Jobs-Signature against jobs_get_signing_secret. idempotency_key dedups for 24h. deliver_after (ISO) / delay_seconds (\u2264 30 d, at most one) defer the first delivery; > 12 h returns state 'delayed'. callback: true (or { ttl_seconds }) returns a single-use keyless callback_url (also on every delivery) that an external worker POSTs with { status: completed, result } / { status: failed, error } / { status: processing, progress, stage, message } \u2014 a handler answering 202 hands the run off until that callback arrives; the result is stored on the run (jobs_get_run).",
|
|
11373
11437
|
inputSchema: {
|
|
11374
11438
|
type: "object",
|
|
11375
11439
|
properties: {
|
|
@@ -11379,7 +11443,14 @@ var TOOLS = [
|
|
|
11379
11443
|
idempotency_key: { type: "string" },
|
|
11380
11444
|
max_attempts: { type: "number", minimum: 1, maximum: 20 },
|
|
11381
11445
|
deliver_after: { type: "string", description: "future ISO timestamp (mutually exclusive with delay_seconds)" },
|
|
11382
|
-
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
|
|
11446
|
+
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
|
|
11447
|
+
callback: {
|
|
11448
|
+
description: "true (24 h) or { ttl_seconds: 60..2678400 }: opt in to the keyless signed callback_url an external worker completes the run with",
|
|
11449
|
+
oneOf: [
|
|
11450
|
+
{ type: "boolean" },
|
|
11451
|
+
{ type: "object", properties: { ttl_seconds: { type: "integer", minimum: 60, maximum: 2678400 } }, additionalProperties: false }
|
|
11452
|
+
]
|
|
11453
|
+
}
|
|
11383
11454
|
},
|
|
11384
11455
|
required: ["job_name", "target_url"]
|
|
11385
11456
|
},
|
|
@@ -11436,7 +11507,7 @@ var TOOLS = [
|
|
|
11436
11507
|
{
|
|
11437
11508
|
name: "jobs_get_run",
|
|
11438
11509
|
feature: "jobs",
|
|
11439
|
-
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true })
|
|
11510
|
+
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null) and, on a generation run, mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written \u2014 or null). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
|
|
11440
11511
|
inputSchema: {
|
|
11441
11512
|
type: "object",
|
|
11442
11513
|
properties: {
|
|
@@ -11724,6 +11795,30 @@ var TOOLS = [
|
|
|
11724
11795
|
method: "POST",
|
|
11725
11796
|
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/shared-links`
|
|
11726
11797
|
},
|
|
11798
|
+
{
|
|
11799
|
+
name: "files_publish",
|
|
11800
|
+
feature: "files",
|
|
11801
|
+
description: "Publish an available file to vxil's public asset host: returns a stable, content-addressed URL served from a global edge cache (cached for a year, immutable), plus image-variant URLs for the tenant's declared presets. Needs files.publicAssets.enabled. Images, mp4/webm video, mp3/m4a/ogg audio, woff2/woff fonts and JSON only \u2014 never HTML or SVG. Counts against the plan's published-bytes ceiling. Server keys only. Idempotent.",
|
|
11802
|
+
inputSchema: {
|
|
11803
|
+
type: "object",
|
|
11804
|
+
properties: { object_id: { type: "string" } },
|
|
11805
|
+
required: ["object_id"]
|
|
11806
|
+
},
|
|
11807
|
+
method: "POST",
|
|
11808
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/publish`
|
|
11809
|
+
},
|
|
11810
|
+
{
|
|
11811
|
+
name: "files_unpublish",
|
|
11812
|
+
feature: "files",
|
|
11813
|
+
description: "Take a published file off the public asset host. Its public copy is deleted once no other file has the same bytes, and the edge cache stops serving it within about a minute and a half. Server keys only; 404 when the file is not published.",
|
|
11814
|
+
inputSchema: {
|
|
11815
|
+
type: "object",
|
|
11816
|
+
properties: { object_id: { type: "string" } },
|
|
11817
|
+
required: ["object_id"]
|
|
11818
|
+
},
|
|
11819
|
+
method: "DELETE",
|
|
11820
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/publish`
|
|
11821
|
+
},
|
|
11727
11822
|
{
|
|
11728
11823
|
name: "comments_create",
|
|
11729
11824
|
feature: "comments",
|
|
@@ -18113,6 +18208,36 @@ export default defineConfig({
|
|
|
18113
18208
|
"process-batch.ts": "// process-batch.ts \u2014 THE WORKER (a vxil function).\n//\n// Trigger: queue. This function has no URL a browser can reach \u2014 it runs because\n// something was ENQUEUED. The envelope it receives is:\n// { trigger: 'queue', tenant_id, request_id, idempotency_key, vxil_base,\n// scoped_jwts, secrets, payload }\n// where `payload` is exactly the object you put inside the enqueue body's\n// `payload.payload`, and `idempotency_key` is stable per run.\n//\n// DELIVERY IS AT-LEAST-ONCE. A retry, an overlapping tick, or a replay can hand\n// you the same batch twice, so correctness cannot rest on \"it runs once\". Here\n// the `request_key` field on `renders` is declared `unique`, which turns the\n// second create into a clean 409 \u2014 and a 409 is not an error, it is the answer\n// \"already done\". That is the whole dedupe strategy: one declared field, and a\n// status code you agree to read as success.\n\nimport type { QueueFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = QueueFunctionEnvelope<{ batch_id?: string; items?: Array<{ key?: string; title?: string; credits?: number }> }>;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n const batchId = String(env.payload?.batch_id ?? env.idempotency_key ?? 'batch');\n const items = Array.isArray(env.payload?.items) ? env.payload!.items! : [];\n if (items.length === 0) return Response.json({ batch_id: batchId, created: 0, duplicates: 0 });\n\n let created = 0;\n let duplicates = 0;\n let rejected = 0;\n\n for (const [i, item] of items.entries()) {\n // Derive a stable key per item so the SAME batch always produces the SAME\n // keys \u2014 that is what makes the redelivery a duplicate rather than a copy.\n const requestKey = String(item.key ?? `${batchId}:${i}`);\n const res = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: { authorization: `Bearer ${cms}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n data: {\n request_key: requestKey,\n title: String(item.title ?? requestKey),\n state: 'queued',\n run_id: env.idempotency_key ?? null,\n credits: typeof item.credits === 'number' ? item.credits : 0,\n created_at: new Date().toISOString(),\n },\n }),\n });\n if (res.ok) created++;\n else if (res.status === 409) duplicates++; // already processed \u2014 the point of `unique`\n else rejected++;\n }\n\n return Response.json({ batch_id: batchId, created, duplicates, rejected });\n },\n};\n"
|
|
18114
18209
|
}
|
|
18115
18210
|
},
|
|
18211
|
+
{
|
|
18212
|
+
"id": "render-farm",
|
|
18213
|
+
"title": "Render Farm (Trigger.dev \xB7 your containers \xB7 credits)",
|
|
18214
|
+
"vertical": "media",
|
|
18215
|
+
"summary": "Long renders and transcodes \u2014 minutes of ffmpeg or a headless browser \u2014 on a runtime you rent (Trigger.dev, Modal, your own containers), while vxil holds what must not be lost: the credits reserved for each render, a deadline, the signed completion callback and the status row the app watches. A per-user unique render key makes a double tap start one render, the failure cause lands on the row, the owner is told once per run, and a cron re-driver drains the backlog when the project is at its in-flight cap.",
|
|
18216
|
+
"collections": [
|
|
18217
|
+
"renders"
|
|
18218
|
+
],
|
|
18219
|
+
"features": [
|
|
18220
|
+
"jobs",
|
|
18221
|
+
"payments",
|
|
18222
|
+
"cms",
|
|
18223
|
+
"notifications",
|
|
18224
|
+
"functions",
|
|
18225
|
+
"files"
|
|
18226
|
+
],
|
|
18227
|
+
"hasFunctions": true,
|
|
18228
|
+
"byoKeys": [
|
|
18229
|
+
"render_url",
|
|
18230
|
+
"render_token",
|
|
18231
|
+
"vxil_jobs_key"
|
|
18232
|
+
],
|
|
18233
|
+
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Render Farm\" \u2014 long renders and transcodes (minutes, ffmpeg, a headless\n// browser) run on a runtime YOU rent: Trigger.dev, Modal, a container on your\n// own cloud account. vxil keeps the four things that must not be lost while\n// that runtime works: the credits held for the render, the deadline, the\n// signed completion callback, and the status row the app watches.\n//\n// request-render (your function, end-user mode)\n// \u2192 creates-or-finds the user's `renders` row (unique render_key)\n// \u2192 POST /v1/jobs/generation in WEBHOOK mode: your render endpoint,\n// credits HELD, a deadline, the status mirrored onto the row\n// vxil's generation lane\n// \u2192 POSTs your render endpoint { render_id, user_id, composition, props,\n// payload, callback_url } with your bearer token\n// your runtime (README: Trigger.dev, or your own containers)\n// \u2192 acks within 20 s, renders, POSTs { status: 'processing', \u2026 } and then\n// { status: 'completed', output_url, duration_s, \u2026 } to callback_url\n// vxil\n// \u2192 completed: credits COMMIT, every callback key lands on the row\n// \u2192 failed / no answer by the deadline: credits REFUNDED, row says failed\n// \u2192 job.generation.completed | failed \u2192 notify-ready tells the owner\n// redrive-pending (cron, every minute)\n// \u2192 starts renders that waited at the concurrency cap (429 \u2014 the app was\n// told `queued: true`), same key; tells the owner if one never starts\n//\n// No container tier and no workflow engine inside vxil: the runtime is yours,\n// the bookkeeping is vxil's. \"Credits\" are usage units on the deterministic\n// `mock` payments integration \u2014 not money; vxil is never in the flow of funds.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n jobs: {\n enabled: true,\n generation: {\n // how many renders may be in flight at once for this project\n maxConcurrent: 20,\n // a render that never calls back fails (and refunds) after 30 minutes\u2026\n defaultTimeoutMs: 1_800_000,\n // \u2026and no render may ask for more than the platform ceiling, one hour\n maxTimeoutMs: 3_600_000,\n // one render never holds more than 50 credits\n maxReserveCredits: 50,\n // all of this project's in-flight renders together hold at most 5,000\n maxOutstandingReserveCredits: 5_000,\n },\n },\n\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {\n render_pack_100: { creditType: 'render_credits', amount: 100, period: 'once' },\n },\n // no subscription tiers in this blueprint \u2014 credits come from packs\n tierMap: {},\n // a render that fails, times out or is cancelled gives its credits back\n autoRefundOnJobFailure: true,\n },\n },\n\n cms: {\n // a render row is live the moment it is written\n draftPublish: false,\n // in end-user mode a signed-in user sees only the renders they own\n strictEndUserScope: true,\n // Lane-A hook (guide ch. 7): render_key IS owner + ':' + request_key,\n // server-enforced, so one user's request_key can never collide with \u2014\n // or block \u2014 another user's.\n hooks: {\n render_key_shape: {\n collection: 'renders',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.render_key == concat(item.owner, ':', item.request_key)\",\n message: \"render_key must be owner + ':' + request_key\",\n },\n },\n },\n\n notifications: { provider: 'mock', fromEmail: 'renders@render-farm.example' },\n functions: { enabled: true },\n\n // the README's keyless-container coordinator uploads each finished output\n // into this project's files (`output_file` below). The per-object ceiling\n // the upload-url call pre-checks is `maxObjectBytes` \u2014 100 MB by default,\n // which fits the coordinator's buffered upload (\"tens of MB\"); raise it\n // (up to 5 GB) for long or high-bitrate renders. Not using the coordinator?\n // Remove this and the `output_file` field.\n files: { enabled: true },\n },\n\n cms: {\n collections: {\n renders: {\n singular: 'render',\n ownerField: 'owner',\n fields: {\n // THE DEDUPE ANCHOR: a double tap or a retried request is a 409 that\n // request-render reads back \u2014 and the same key is the generation\n // run's idempotency_key, so the render endpoint is asked once.\n render_key: { type: 'string', required: true, unique: true, indexSlot: 's1' },\n request_key: { type: 'string', required: true },\n owner: { type: 'string', indexSlot: 's2' },\n // which composition / preset your runtime renders (your vocabulary)\n composition: { type: 'string', required: true, indexSlot: 's3' },\n // written by vxil's status mirror: pending \u2192 processing \u2192 completed | failed\n status: { type: 'string', indexSlot: 's4' },\n credits: { type: 'int', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n props: { type: 'json' },\n run_id: { type: 'text' },\n // \u2500\u2500 keys your runtime sends back. EVERY key of the completion body is\n // written onto this row, so each one must be a declared field (a\n // write naming an unknown field is refused: vxil then writes the\n // status word alone and the run reports `mirror_error` with the\n // keys it dropped \u2014 declare the field so its value lands too).\n output_url: { type: 'text' },\n // the files-feature object id, when a coordinator uploads the output\n // into this project's files (README \"Keys stay out of the container\")\n output_file: { type: 'file' },\n duration_s: { type: 'float' },\n // progress keys: a `processing` ping carries them onto the row\n // (request-render's status_mirror.progress_fields \u2014 progress 0..100,\n // a float: the run keeps 2 decimals,\n // stage \u2264 64 chars, message \u2264 200), and the completion body writes\n // them too (progress: 100, stage: 'done').\n progress: { type: 'float', validation: { min: 0, max: 100 } },\n stage: { type: 'string' },\n message: { type: 'text' },\n // written by notify-ready from job.generation.failed (and by\n // redrive-pending when a render never got a run)\n error: { type: 'text' },\n // written by redrive-pending: how often it tried to start this render,\n // and when it last did. The re-driver's read needs no new index \u2014\n // `status` (s4) and `created_at` (t1) are slots; `run_id: null` is a\n // residual test over the rows they pick.\n redrive_attempts: { type: 'int' },\n redriven_at: { type: 'datetime' },\n },\n },\n },\n },\n\n functions: {\n // Starts ONE render for the signed-in user. Invoke it in END-USER mode\n // (with the user's session): the held credits are forced onto that user,\n // and the row is theirs.\n 'request-render': {\n entry: './functions/request-render.ts',\n trigger: { kind: 'http' },\n scopes: ['cms:read', 'cms:write', 'jobs:write'],\n // render_url: your render endpoint (https). render_token: the bearer\n // token that endpoint checks. Both ride the generation run; vxil's\n // generation lane \u2014 not this function \u2014 calls the endpoint.\n secrets: ['secret:render_url', 'secret:render_token'],\n egressAllow: [],\n signature: {\n input: { composition: 'string', request_key: 'string', props: 'json?' },\n output:\n '{ item_id: string; run_id: string; credits: number }'\n + ' | { duplicate: true; request_key: string; item_id: string; run_id: string | null; status: string }',\n },\n },\n\n // THE BACKLOG RE-DRIVER. At the generation cap request-render answers 429\n // and the row waits `pending` with no run; every minute this starts the\n // oldest such rows (\u2265 30 s old, 20 per tick) with the SAME idempotency key,\n // stops at the first 429 (or at a refusal that means the setup is wrong),\n // and fails \u2014 and tells the owner of \u2014 a row that waited over an hour.\n // overlap 'skip': a slow tick is never doubled by the next one.\n // Free plan: a function cron may fire at most every 15 minutes \u2014 use\n // '*/15 * * * *' there (README \"The backlog\").\n 'redrive-pending': {\n entry: './functions/redrive-pending.ts',\n trigger: { kind: 'cron', schedule: '* * * * *', overlap: 'skip' },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n // vxil_jobs_key: an API key of this backend holding ONLY jobs:write \u2014 a\n // cron tick has no signed-in user to hold credits for, so the start is\n // made as your trusted server (README \"The backlog\")\n secrets: ['secret:vxil_jobs_key', 'secret:render_url', 'secret:render_token'],\n egressAllow: [],\n },\n\n // job.generation.completed | failed \u2192 write the failure cause onto the row\n // and tell the owner. A non-2xx is retried; the notification's\n // Idempotency-Key (one per run) makes a redelivery send nothing twice.\n 'notify-ready': {\n entry: './functions/notify-ready.ts',\n trigger: { kind: 'webhook', source: 'job.generation.', retry: { maxAttempts: 3 } },\n scopes: ['cms:read', 'cms:write', 'notifications:send'],\n egressAllow: [],\n },\n },\n\n secrets: {\n render_url: {\n feature: 'functions',\n description: 'your render endpoint \u2014 the https URL vxil POSTs each render to (a Trigger.dev relay, or your own container endpoint)',\n },\n render_token: {\n feature: 'functions',\n description: 'a long random token your render endpoint checks on the Authorization header (Bearer \u2026)',\n },\n vxil_jobs_key: {\n feature: 'functions',\n description: 'an API key of this backend holding ONLY jobs:write \u2014 redrive-pending starts backlogged renders with it (vxil keys mint --name render-redrive --scopes jobs:write). jobs:write also lets it cancel or replay any run, enqueue jobs and manage schedules and flow rules: a server key, kept only here',\n },\n },\n});\n",
|
|
18234
|
+
"readme": "# Render Farm \u2014 long renders on a runtime you rent, with vxil holding the credits, the deadline and the callback\n\n```bash\nvxil init my-renders --template render-farm\ncd my-renders\nprintf '%s' \"$RENDER_URL\" | vxil secrets set functions/render_url # your render endpoint (https)\nprintf '%s' \"$RENDER_TOKEN\" | vxil secrets set functions/render_token # a long random token it checks\n# the backlog re-driver's key: an API key of this backend holding ONLY jobs:write\nvxil keys mint --name render-redrive --scopes jobs:write --json | jq -r .api_key | vxil secrets set functions/vxil_jobs_key\nvxil push\n```\n\n> **Plan note.** The functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (`vxil projects workload <slug> development`, or create it with\n> `vxil projects create <slug> --workload development`). On a Free `production` project, `vxil push` stops before it writes anything, naming the plan and the ways out: change the workload or upgrade to Developer, or run `vxil push --skip-functions` to apply the collections and config without the functions.\n> On the Free plan a function cron may also fire at most every 15 minutes, so change `redrive-pending`'s\n> schedule to `'*/15 * * * *'` there. The blueprint is written for Developer and up, where it runs every\n> minute. Everything else works the same; a backlog just drains more slowly.\n\nA video render, a transcode, a headless-browser capture: minutes of CPU, ffmpeg or Chromium. That does\nnot fit in a vxil function (a delivered trigger gets about a minute), and vxil will not grow a container\ntier or a workflow engine to run it. So the work runs on **a runtime you rent** \u2014 Trigger.dev, Modal,\na container on your own cloud account \u2014 and vxil keeps the four things that must survive while it\nruns:\n\n| vxil holds | so that |\n|---|---|\n| **the credits** reserved for the render | a failed, abandoned or cancelled render gives them back, and a user can never start more than they can pay for |\n| **the deadline** | a render your runtime never reports on fails and refunds after 30 minutes (at most one hour) |\n| **the signed completion callback** | your runtime needs no vxil key: the URL it is handed is the credential for that one render |\n| **the status row** | the app reads (or subscribes to) one `renders` row: `pending \u2192 processing \u2192 completed | failed`, plus everything your runtime sent back |\n\n**What this blueprint teaches that the others do not:** the hand-off to **your own** long-running\nruntime through a **webhook-mode generation run** \u2014 the contract your endpoint and your worker must\nkeep, and two complete runtime options below. (`fal-media` shows the same lane against a vendor queue\nAPI; `job-runner` shows a provider call vxil polls.)\n\n## What you get\n\n- **`renders`** \u2014 one row per render, owned by the user who asked for it (`strictEndUserScope`: a\n signed-in user reads only their own). `render_key` is the owner + `:` + the client's `request_key`\n (the composition is enforced by a `beforeWrite` hook) and is **unique**, so a double tap or a retried\n request finds the first row instead of starting a second render. Your runtime's answer lands on the\n row: `output_url`, `duration_s`, `progress`, `stage`, `message`.\n- **`request-render`** (http function, end-user mode) \u2014 creates-or-finds the row, then starts ONE\n generation run: your endpoint (`render_url`), your token on its `Authorization` header, a status\n mirror onto the row, `reserve_credits` for the render (5 `render_credits`), a 30-minute deadline, and\n the `render_key` as the run's `idempotency_key` \u2014 so a re-driven start gets the same run back. The\n credits it holds are the function's fixed price, never a value read from the row.\n- **`redrive-pending`** (cron function, every minute, `overlap: 'skip'`) \u2014 drains the backlog. When\n the project already has `generation.maxConcurrent` renders in flight, `request-render` answers `429`\n (with `queued: true`) and the row waits `pending` with no run. This function starts those rows,\n oldest first, with the **same** idempotency key, and tells the owner when one never starts\n ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **`notify-ready`** (webhook function on `job.generation.`) \u2014 writes the failure cause onto the row (a\n platform class such as `GenerationExpired` gets a short human hint after it) and sends the owner one\n message per run (`Idempotency-Key: render-ready:<run_id>`). A render refused for too few credits\n keeps `insufficient_credits`; when `request-render` refused it, the caller already got the `402`\n and nothing is sent, and when the re-driver started it (the user last heard \"queued\"), the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`, the re-driver's own key).\n- **credits** \u2014 a payments integration on the `mock` provider (no provider account needed to try it).\n \"Credits\" are usage units you meter, not money.\n\nThe row is a **view** for the app; the run and the ledger are the truth. If your app's client key\ncarries `cms:write`, a signed-in user can edit their own `renders` row (say, set `status` to\n`completed`) \u2014 that changes nothing they are charged or given. Anything that grants something on\ncompletion should read the run (`GET /v1/jobs/runs/{run_id}`) or react to `job.generation.completed`,\nas `notify-ready` does \u2014 or give the client key only `cms:read`.\n\n## The contract your runtime keeps\n\nWhatever runs the render, these are the only things it has to do:\n\n| Step | What arrives / what to send |\n|---|---|\n| **Start** | vxil `POST`s your `render_url` with `Authorization: Bearer <render_token>` and JSON `{ render_id, user_id, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }` (`user_id` is the render's owner). Check the token, **queue the work and answer `2xx` within 20 seconds** \u2014 never render inline. `408` / `429` / `5xx` / a timeout is retried with backoff; any other `4xx` ends the run and refunds the credits. A start can arrive more than once (a lost answer is retried), so key the work by `render_id`. |\n| **Progress** (optional) | `POST callback_url` with `{\"status\": \"processing\", \"progress\": 40, \"stage\": \"encoding\"}`. A processing ping moves the row to `processing` and writes its `progress` (0\u2013100) / `stage` (\u2264 64 chars) / `message` (\u2264 200 chars) onto the row \u2014 `request-render` asks for that with `status_mirror.progress_fields` \u2014 so the app shows live progress by watching the row. Other keys on a ping are not written to the row (the run keeps the latest report, `GET /v1/jobs/runs/{run_id}` \u2192 `progress`). Keep pings to one every few seconds at most. |\n| **Done** | `POST callback_url` with `{\"status\": \"completed\", \"output_url\": \"https://\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` \u2014 or, when the output is uploaded into this project's files, `{\"status\": \"completed\", \"output_file\": \"obj_\u2026\", \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\"}` ([below](#keys-stay-out-of-the-container-the-recommended-shape)). At most 256 KiB, and **every key a declared field of `renders`** (add a field before you send a new key; [test it](#a-contract-test-for-your-runtime)). The credits are committed and every key is written onto the row. |\n| **Failed** | `POST callback_url` with `{\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"ffmpeg exited 1: \u2026\"}`. The credits are refunded; `error` (a short code) and `hint` reach `notify-ready` as `error_class` / `error_hint` and land on the row's `error`. |\n| **Never answers** | the run fails at the deadline (`GenerationExpired`), the credits are refunded, and a callback after that changes nothing. |\n| **The deadline** | `payload.deadline_at` (an ISO time) is when vxil stops waiting. Check it before each attempt starts: a render that cannot finish by then should post `failed` and stop. A `completed` posted after it is answered with the settled `failed` state \u2014 the output exists, but the user was refunded and the row says failed. |\n\n`callback_url` needs no other credential \u2014 and nothing else should see it. A repeated `completed` or\n`failed` post is answered with the settled state and changes nothing, so your worker can safely retry\nits own callback on a network error. The output bytes stay where your runtime wrote them (your bucket,\nyour CDN) and vxil stores the keys, not the file, unless a coordinator uploads the output into this\nproject's files ([next section](#keys-stay-out-of-the-container-the-recommended-shape)).\n\nEach start request also carries an `X-Vxil-Jobs-Signature` header (verifiable with your project's\njobs signing secret, `GET /v1/jobs/signing-secret`) and `x-vxil-run-id`. This blueprint uses the\nbearer token because it is one string comparison in any language.\n\n## Keys stay out of the container (the recommended shape)\n\nThe render container is the part of your system that runs the most third-party code (ffmpeg,\nChromium, fonts and media from the user's props), so give it **no vxil key at all**. This is the\nshape the blueprint recommends, whatever runs the container:\n\n```\nvxil \u2500\u2500start\u2500\u2500\u25B6 coordinator \u2500\u2500launch\u2500\u2500\u25B6 container\n (render_url) \u2502 progress / failed \u2500\u2500\u2500\u2500\u2500\u2500\u25B6 callback_url (keyless)\n \u25B2 output bytes \u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 mints the upload URL with ITS files:write key, PUTs the bytes,\n completes the object, POSTs \"completed\" + output_file \u2500\u2500\u25B6 callback_url\n```\n\n- **The files feature is on.** The blueprint's config enables it (`files: { enabled: true }`) and\n declares `output_file` as a `file` field; without it every upload-url call is refused and the\n render waits out its deadline. Its per-object ceiling, `maxObjectBytes`, is 100 MB by default,\n enough for the buffered coordinator below; raise it for long or high-bitrate renders.\n- **The coordinator** is your `render_url`: a small endpoint in your own account \u2014 an edge worker\n with a per-render lock, or a route on any thin server. It is the only piece that holds a vxil key,\n and that key holds only **`files:write`** (`vxil keys mint --name render-uploads --scopes files:write`).\n- **The container** gets the job, the keyless `callback_url` (for `processing` pings and for\n `failed`), an `output_url` on the coordinator and an **output ticket** for it: a token signed for\n that one render and useless after its deadline, sent on the `Authorization` header (never in the\n URL, where access logs would keep it).\n- **The upload happens once the size is known.** The container POSTs the finished file to its\n ticket. The coordinator reads it, mints the files upload URL for exactly that size\n (the quota pre-check uses it), PUTs the bytes, completes the object, and only then posts\n `completed` with the object id as `output_file`. A crash anywhere before that leaves the render\n open, and it refunds at the deadline like any other.\n\n```ts\n// coordinator.ts \u2014 your render_url. A standard fetch handler (an edge worker, or a Node 18+ adapter).\n// Env: RENDER_TOKEN (= the vxil secret render_token), TICKET_SECRET (a long random string),\n// VXIL_FILES_KEY (an API key holding ONLY files:write), VXIL_BASE (https://api.vxil.com).\ntype Start = {\n render_id: string; user_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; deadline_at: string }; callback_url: string;\n};\ntype Ticket = { render_id: string; user_id: string; callback_url: string; exp: number };\ntype Env = { RENDER_TOKEN: string; TICKET_SECRET: string; VXIL_FILES_KEY: string; VXIL_BASE: string };\n\nconst enc = new TextEncoder();\nconst b64u = (b: ArrayBuffer | Uint8Array) =>\n btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\nconst unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\nconst hmacKey = (secret: string) =>\n crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);\nasync function sealTicket(env: Env, t: Ticket): Promise<string> {\n const body = b64u(enc.encode(JSON.stringify(t)));\n return `${body}.${b64u(await crypto.subtle.sign('HMAC', await hmacKey(env.TICKET_SECRET), enc.encode(body)))}`;\n}\nasync function openTicket(env: Env, raw: string): Promise<Ticket | null> {\n const [body, sig] = raw.split('.');\n if (!body || !sig) return null;\n try {\n // crypto.subtle.verify compares in constant time (a `!==` on the signature would not)\n if (!(await crypto.subtle.verify('HMAC', await hmacKey(env.TICKET_SECRET), unb64u(sig), enc.encode(body)))) return null;\n const t = JSON.parse(new TextDecoder().decode(unb64u(body))) as Ticket;\n return t.exp > Date.now() ? t : null;\n } catch {\n return null; // not base64url / not JSON\n }\n}\n/** The output's file extension, from the Content-Type the container sends. */\nconst EXT: Record<string, string> = {\n 'video/mp4': 'mp4', 'video/webm': 'webm', 'image/gif': 'gif', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/pdf': 'pdf',\n};\nconst vxil = (env: Env, path: string, body?: unknown) => fetch(`${env.VXIL_BASE}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${env.VXIL_FILES_KEY}`, 'content-type': 'application/json' },\n ...(body ? { body: JSON.stringify(body) } : {}),\n});\n\nexport default {\n async fetch(req: Request, env: Env): Promise<Response> {\n const url = new URL(req.url);\n\n // 1. the start: check the token, launch the container, answer inside 20 s\n if (req.method === 'POST' && url.pathname === '/start') {\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) return new Response('unauthorized', { status: 401 });\n const s = (await req.json()) as Start;\n // a run started before request-render sent user_id (an older copy of this\n // blueprint): a server-mode upload must name its user, so refuse the start \u2014\n // a 4xx ends that run and refunds it, instead of a 422 at upload time\n if (!s.user_id) return new Response('start body has no user_id: redeploy request-render', { status: 400 });\n const ticket = await sealTicket(env, {\n render_id: s.render_id, user_id: s.user_id, callback_url: s.callback_url,\n exp: Date.parse(s.payload.deadline_at), // useless once vxil stops waiting\n });\n await launchContainer({ // YOUR container platform's API, keyed by\n render_id: s.render_id, // render_id: a start vxil re-sends launches once\n composition: s.composition, props: s.props ?? {},\n deadline_at: s.payload.deadline_at,\n callback_url: s.callback_url, // for processing pings and `failed`\n output_url: `${url.origin}/output`, // POST the file here, with\n output_ticket: ticket, // Authorization: Bearer <output_ticket>\n });\n return Response.json({ accepted: true }, { status: 202 });\n }\n\n // 2. the output: the container POSTs the finished file here, once\n if (req.method === 'POST' && url.pathname === '/output') {\n const t = await openTicket(env, (req.headers.get('authorization') ?? '').replace(/^Bearer /, ''));\n if (!t) return new Response('bad or expired ticket', { status: 403 });\n const contentType = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();\n const ext = EXT[contentType];\n if (!ext) return new Response(`send the output's Content-Type (one of: ${Object.keys(EXT).join(', ')})`, { status: 415 });\n // buffered: fine for outputs of tens of MB \u2014 see \"Very large outputs\" below\n const bytes = await req.arrayBuffer();\n const size = bytes.byteLength;\n if (size === 0) return new Response('empty output', { status: 400 });\n\n const minted = await vxil(env, '/v1/files/upload-url', {\n user_id: t.user_id, filename: `${t.render_id}.${ext}`, content_type: contentType, size_bytes: size,\n });\n if (!minted.ok) return new Response(`upload-url ${minted.status}`, { status: 502 }); // the container sends the file again\n const { object_id, upload_url } = ((await minted.json()) as { data: { object_id: string; upload_url: string } }).data;\n const put = await fetch(upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });\n if (!put.ok) return new Response(`upload ${put.status}`, { status: 502 });\n const done = await vxil(env, `/v1/files/${encodeURIComponent(object_id)}/complete`);\n if (!done.ok) return new Response(`complete ${done.status}`, { status: 502 });\n\n // 3. settle the render, on the same keyless callback the container uses\n const settled = await fetch(t.callback_url, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: 'completed', output_file: object_id, progress: 100, stage: 'done' }),\n });\n return settled.ok ? Response.json({ object_id }) : new Response(`callback ${settled.status}`, { status: 502 });\n }\n return new Response('not found', { status: 404 });\n },\n};\n\ndeclare function launchContainer(job: Record<string, unknown>): Promise<void>; // your container platform's API\n```\n\nThe container's side is three kinds of HTTP call and no vxil key: `processing` pings to\n`callback_url`, the file to `output_url` (with `Authorization: Bearer <output_ticket>` and the\nfile's `Content-Type`), and `{\"status\": \"failed\", \u2026}` to `callback_url` if it gives up. Four notes\non the shape:\n\n- **A retried output post** (the container saw a network error after the coordinator had uploaded)\n mints a second object, and the second `completed` is answered with the settled state and changes\n nothing. Delete the extra object, or keep a `render_id \u2192 object_id` note in the coordinator's own\n store and skip the upload when one exists.\n- **Very large outputs.** The coordinator above holds the whole file in memory, and passing gigabytes\n through it costs its bandwidth too. For big files, have the container report the size first\n (`POST /output-url` with its ticket on the `Authorization` header and `{ size_bytes }`) and let the coordinator answer with the\n presigned upload URL it minted; the container PUTs straight to it, and the coordinator completes\n the object and posts `completed` when the container says it is done. The container still holds no\n key: a presigned URL is good for one object for a few minutes.\n- **Upgrading an earlier copy of this blueprint.** Renders started before `request-render` put\n `user_id` in the start body have none, and a server-mode upload must name its user. The\n coordinator refuses such a start with a `400`, which ends that run and refunds it at once;\n redeploy `request-render` (`vxil push`) before you point `render_url` at the coordinator.\n- **Serving it.** The row now holds a files object id. Read it back with a signed download URL, or\n publish it from a settle function with a server key (`vx.files.publish(object_id)`) for a stable\n public URL served from the edge cache.\n\nOptions A and B below show the same contract with the runtime posting `completed` itself (an\n`output_url` in your own bucket). Either can adopt the coordinator: point `render_url` at it, and have\nstep 1 trigger the Trigger.dev task or spawn the Modal function.\n\n## Option A \u2014 Trigger.dev (v4)\n\nTrigger.dev runs the render as a task on a machine you pick, with ffmpeg or Chromium baked into the\nimage, retries, and its own run dashboard. Two pieces: a **relay endpoint** that turns vxil's start\nrequest into a Trigger.dev trigger, and the **task**.\n\n**Why a relay, and not `render_url` pointed straight at Trigger.dev's trigger API?** vxil sends\n`callback_url` beside `payload` at the top level of the body, and Trigger.dev's trigger API passes\nonly `payload` to the task \u2014 the task would never see where to report. The relay is ~30 lines and is\nalso where your `render_token` is checked. Host it anywhere that serves https: a serverless function on\nyour web host, a small edge worker, a route in your existing API.\n\n```ts\n// relay.ts \u2014 your render_url. Standard fetch handler (edge worker / serverless function / Node 18+ adapter).\n// Env: RENDER_TOKEN (the same value as the vxil secret render_token), TRIGGER_SECRET_KEY (tr_prod_\u2026 / tr_dev_\u2026).\ntype Start = {\n render_id: string; composition: string; props?: Record<string, unknown>;\n payload: { generation_id: string; correlation_id?: string; deadline_at: string }; callback_url: string;\n};\n\nexport default {\n async fetch(req: Request, env: { RENDER_TOKEN: string; TRIGGER_SECRET_KEY: string }): Promise<Response> {\n if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });\n if (req.headers.get('authorization') !== `Bearer ${env.RENDER_TOKEN}`) {\n return new Response('unauthorized', { status: 401 }); // a 4xx ends the vxil run (and refunds)\n }\n const s = (await req.json()) as Start;\n const res = await fetch('https://api.trigger.dev/api/v1/tasks/render-video/trigger', {\n method: 'POST',\n headers: { authorization: `Bearer ${env.TRIGGER_SECRET_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n payload: {\n render_id: s.render_id, composition: s.composition, props: s.props ?? {},\n callback_url: s.callback_url, deadline_at: s.payload.deadline_at,\n },\n options: {\n // a start vxil re-sends (a lost answer) triggers the SAME Trigger.dev run\n idempotencyKey: `render:${s.render_id}`,\n // a render still queued after 3 minutes is dropped (it never runs, and no\n // onFailure fires \u2014 vxil refunds it at its deadline). Part of the budget below.\n ttl: '3m',\n tags: [`render_${s.render_id}`],\n },\n }),\n });\n if (res.ok) return Response.json({ accepted: true }, { status: 202 });\n // Trigger.dev busy or down: answer 503 and vxil tries the start again; anything else ends the run\n return new Response(`trigger.dev ${res.status}`, { status: res.status === 429 || res.status >= 500 ? 503 : 400 });\n },\n};\n```\n\n```ts\n// trigger.config.ts \u2014 ffmpeg and Chromium in the task image\nimport { defineConfig } from '@trigger.dev/sdk';\nimport { ffmpeg } from '@trigger.dev/build/extensions/core';\nimport { puppeteer } from '@trigger.dev/build/extensions/puppeteer';\n\nexport default defineConfig({\n project: '<your project ref>',\n dirs: ['./trigger'],\n maxDuration: 600, // CPU seconds PER ATTEMPT \u2014 the task sets its own; see the budget below\n build: { extensions: [ffmpeg(), puppeteer()] }, // puppeteer also needs PUPPETEER_EXECUTABLE_PATH set in the Trigger.dev env\n});\n```\n\n```ts\n// trigger/render-video.ts \u2014 the task: render, upload, report to vxil\nimport { task, metadata, logger } from '@trigger.dev/sdk';\n\ntype Payload = {\n render_id: string; composition: string; props: Record<string, unknown>;\n callback_url: string; deadline_at: string; // when vxil stops waiting (ISO)\n};\n\n/** The longest one attempt takes, wall clock, with margin. An attempt that cannot\n * finish before deadline_at does not start: it tells vxil, so the credits come back now. */\nconst ATTEMPT_WALL_MS = 12 * 60_000;\n\n/** POST one status to vxil. A 5xx/network error throws, so Trigger.dev retries the attempt;\n * a repeated completed/failed post is a no-op on vxil's side. */\nasync function report(callbackUrl: string, body: Record<string, unknown>): Promise<void> {\n const res = await fetch(callbackUrl, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),\n });\n if (res.status >= 500) throw new Error(`callback to vxil answered ${res.status}`);\n}\n\nexport const renderVideo = task({\n id: 'render-video',\n machine: 'large-1x', // 4 vCPU / 8 GB \u2014 size to your renders\n maxDuration: 600, // CPU seconds per attempt (10 min) \u2014 not wall time\n retry: { maxAttempts: 2, minTimeoutInMs: 5_000, maxTimeoutInMs: 30_000 },\n run: async (p: Payload) => {\n if (Date.now() + ATTEMPT_WALL_MS > Date.parse(p.deadline_at)) {\n // too late to finish inside vxil's deadline: refund now, and do not retry\n await report(p.callback_url, { status: 'failed', error: 'deadline', hint: 'no time left for another attempt' });\n return { skipped: 'deadline' };\n }\n metadata.set('stage', 'rendering'); // Trigger.dev's own run view\n await report(p.callback_url, { status: 'processing', stage: 'rendering', progress: 0 });\n\n // \u2026your render: drive Chromium for frames, run ffmpeg, write the file\n // to YOUR bucket keyed by render_id (so a retried attempt overwrites, not duplicates)\u2026\n const outputUrl = `https://cdn.example.com/renders/${p.render_id}.mp4`;\n const durationS = 31.2;\n logger.info('rendered', { render_id: p.render_id, outputUrl });\n if (Date.now() > Date.parse(p.deadline_at)) {\n // vxil has already failed and refunded this render: the post below is answered\n // with that settled state. Your sizing is off \u2014 widen the budget below.\n logger.warn('finished after the vxil deadline', { render_id: p.render_id });\n }\n\n await report(p.callback_url, {\n status: 'completed', output_url: outputUrl, duration_s: durationS, progress: 100, stage: 'done',\n });\n return { output_url: outputUrl };\n },\n // after the last attempt THROWS: tell vxil, so the credits come back now, not at the\n // deadline. Not called when an attempt exceeds maxDuration or the run expires on its\n // ttl \u2014 those refund only at vxil's deadline.\n onFailure: async ({ payload, error }) => {\n await report(payload.callback_url, {\n status: 'failed', error: 'render_failed', hint: String(error instanceof Error ? error.message : error).slice(0, 200),\n });\n },\n});\n```\n\n**Budget the wall clock.** Trigger.dev's limits and vxil's deadline are separate clocks, and only\nvxil's refunds. Size them so a render always ends \u2014 `completed` or `failed` \u2014 before vxil's deadline:\n\n```\nttl + maxAttempts \xD7 (longest attempt, wall clock) + retry backoff < timeout.after_ms\n3 min + 2 \xD7 12 min + \u2264 1 min = 28 min < 30 min\n```\n\n`maxDuration` counts **CPU time per attempt**, not wall time across the run, so it does not bound\nthe sum: the `deadline_at` check at the start of each attempt does. If your renders need more, raise\n`RENDER_DEADLINE_MS` in `request-render` (up to `generation.maxTimeoutMs`, one hour) and resize the\nrest to fit.\n\n**Be honest with yourself about three things before you ship on Trigger.dev Cloud:**\n\n- **Data residency.** Trigger.dev Cloud keeps its operational and log data \u2014 including each run's\n payload \u2014 in the US (us-east-1), even when the machines run elsewhere. Your render props and the\n `callback_url` pass through it. If that rules it out, self-host Trigger.dev for your own app, or use\n option B.\n- **The callback URL is a credential for one render.** It appears in Trigger.dev's run payload and\n dashboard. It can settle only that render, and stops mattering once the render is settled.\n- **Two clocks, and `onFailure` is not a guarantee.** Trigger.dev calls `onFailure` only after the last\n attempt throws. A run that exceeds `maxDuration`, or expires on its `ttl` before it starts, ends\n without it \u2014 vxil refunds those at its deadline, not sooner. Keep the budget above, and keep the\n `deadline_at` check, so a late attempt refunds early instead of finishing after the refund.\n\n## Option B \u2014 your own container runtime (Modal, Fly, a container on your own cloud account)\n\nSame contract, no relay: the endpoint you deploy **is** `render_url`. It must answer within 20 seconds,\nso it only checks the token, hands the job to a background worker and answers `2xx`; the worker renders\nand posts back. On Modal:\n\n```python\n# render_app.py \u2014 `modal deploy render_app.py`; render_url = the endpoint's https URL\nimport json, os, urllib.request\nfrom datetime import datetime, timedelta, timezone\nimport modal\nfrom fastapi import HTTPException, Request # also `pip install fastapi` where you run `modal deploy`\n\nimage = (modal.Image.debian_slim()\n .apt_install(\"ffmpeg\", \"chromium\")\n .pip_install(\"fastapi[standard]\"))\napp = modal.App(\"render-farm\", image=image)\nsecrets = [modal.Secret.from_name(\"render-farm\")] # RENDER_TOKEN\n\ndef report(callback_url: str, body: dict) -> None:\n req = urllib.request.Request(callback_url, data=json.dumps(body).encode(),\n headers={\"content-type\": \"application/json\"}, method=\"POST\")\n urllib.request.urlopen(req, timeout=30)\n\nATTEMPT_WALL_S = 25 * 60 # = the timeout below; a call cut off there may never reach its except\n\n@app.function(cpu=4, memory=8192, timeout=ATTEMPT_WALL_S, secrets=secrets)\ndef render(job: dict) -> None:\n cb = job[\"callback_url\"]\n deadline = datetime.fromisoformat(job[\"payload\"][\"deadline_at\"].replace(\"Z\", \"+00:00\"))\n if datetime.now(timezone.utc) + timedelta(seconds=ATTEMPT_WALL_S) > deadline:\n # queued too long to finish before vxil stops waiting: refund now\n report(cb, {\"status\": \"failed\", \"error\": \"deadline\", \"hint\": \"started too late to finish\"})\n return\n try:\n report(cb, {\"status\": \"processing\", \"stage\": \"rendering\", \"progress\": 0})\n # \u2026render with ffmpeg / chromium, upload to YOUR bucket keyed by job[\"render_id\"]\u2026\n output_url = f\"https://cdn.example.com/renders/{job['render_id']}.mp4\"\n report(cb, {\"status\": \"completed\", \"output_url\": output_url, \"duration_s\": 31.2,\n \"progress\": 100, \"stage\": \"done\"})\n except Exception as e: # tell vxil now, so the credits come back before the deadline\n report(cb, {\"status\": \"failed\", \"error\": \"render_failed\", \"hint\": str(e)[:200]})\n raise\n\n@app.function(secrets=secrets)\n@modal.fastapi_endpoint(method=\"POST\")\nasync def start(request: Request):\n if request.headers.get(\"authorization\") != f\"Bearer {os.environ['RENDER_TOKEN']}\":\n raise HTTPException(status_code=401, detail=\"unauthorized\") # a 4xx ends the vxil run\n job = await request.json()\n await render.spawn.aio(job) # queued; returns at once, well inside the 20-second window\n return {\"accepted\": True}\n```\n\n`spawn` queues the call and returns immediately, so the endpoint answers in well under a second. A\nstart can arrive twice (a lost answer is retried), so dedupe on `render_id`: keep the ids you have\nspawned in a `modal.Dict`, or make the render overwrite the same output key.\n\nAnything else that can (1) answer an https POST in under 20 s, (2) run the work in the background and\n(3) POST JSON to a URL fits the same contract: a Fly Machine started per job, a container service with\na queue in front, your own GPU box. vxil does not care what runs the render \u2014 only that the start is\nacknowledged quickly and the callback eventually comes.\n\n## Run it\n\nGive a user some credits from your server (or sell `render_pack_100` through your payments provider):\n\n```bash\ncurl -s -X POST \"https://api.vxil.com/v1/payments/credits/grant\" \\\n -H \"authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n -H 'idempotency-key: welcome-u1' \\\n -d '{\"user_id\":\"<the user id>\",\"credit_type\":\"render_credits\",\"amount\":25,\"source\":\"welcome\"}'\n```\n\nStart a render **with the user's session** (end-user mode \u2014 the held credits are forced onto that\nuser):\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// after `vxil gen`, vx.fn['request-render'] is typed from the function's declared signature\nconst vx = new Vxil({ apiKey: process.env.VXIL_PUBLISHABLE_KEY!, endUserToken: process.env.USER_SESSION! });\nconst started = await vx.fn['request-render']({\n composition: 'promo-30s', request_key: 'promo-1', props: { headline: 'Spring sale' },\n});\n// \u2192 { item_id, run_id, credits: 5 }\n// (or { duplicate: true, request_key, item_id, run_id, status } on a retry)\n```\n\nThen read the row \u2014 or subscribe to its changes \u2014 until `status` is `completed`:\n\n```ts\nif ('item_id' in started) {\n const row = await vx.from('renders').get(started.item_id);\n // row.status \u2192 'completed', row.output_url \u2192 'https://cdn.example.com/renders/\u2026.mp4'\n}\n```\n\n**Try it before you have a runtime.** Point `render_url` at any https endpoint that answers `2xx`\n(a request-bin works) and play the runtime yourself: copy `callback_url` from the request it received,\nthen\n\n```bash\ncurl -s -X POST \"$CALLBACK_URL\" -H 'content-type: application/json' \\\n -d '{\"status\":\"completed\",\"output_url\":\"https://cdn.example.com/x.mp4\",\"duration_s\":12.5,\"progress\":100,\"stage\":\"done\"}'\n# \u2192 { \"data\": { \"run_id\": \"run_\u2026\", \"generation_status\": \"completed\" } } \u2014 and the row says so\n```\n\n## How it fails, and what the user sees\n\n| what happened | the run | the row | the credits |\n|---|---|---|---|\n| runtime posted `completed` | `completed` | `status: completed` + every key it sent | committed |\n| runtime posted `failed` | `failed` | `status: failed`, `error` = its code + hint (written by `notify-ready`) | refunded |\n| runtime never called back | `failed` (`GenerationExpired`) at the deadline | `failed`, `error: GenerationExpired: the render did not finish before its deadline` | refunded |\n| runtime finished after the deadline | already `failed`; the late `completed` is answered with that state | `failed` | refunded (your compute was spent \u2014 budget the clocks) |\n| your endpoint answered `5xx` / timed out | start retried with backoff; terminal after the attempts | `processing` \u2192 `failed`, `error: RetryableHttp: the render endpoint kept failing to accept the render (retries exhausted)` (or `NetworkError: \u2026` when it could not be reached) | held until then, then refunded |\n| your endpoint answered another `4xx` (a bad token) | `failed` at once | `failed` | refunded |\n| the user had too few credits (at `request-render`) | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent; the caller got the `402`, no message | nothing held |\n| too many renders in flight, or a jobs-side fault | not created yet (the caller gets `429` with `queued: true` and `retry_after`, or `502` with `queued: true`) | `pending`, no run \u2014 **queued**: `redrive-pending` starts it when a slot frees up (or the app calls again with the **same** `request_key`) | held when it starts |\n| the user had too few credits when the re-driver started it | ended at once (`ReserveInsufficient`) | `failed`, `error: insufficient_credits`; the owner is told once (they last heard \"queued\") | nothing held |\n| still no free slot an hour later | never created | `failed`, `error: not_started: the render waited too long for a free slot` (written by `redrive-pending`); the owner is told once | nothing held |\n| the re-driver's start was refused (`400` / `401` / `403` / `422`: a non-https or private `render_url`, a wrong or revoked `vxil_jobs_key`) | not created | still `pending` \u2014 the tick stops and reports it (`stopped: { status, code }` in the function's logs); fix the setup and the next tick carries on; the one-hour bound still applies | nothing held |\n\n## The backlog: `maxConcurrent`, `429` and the re-driver\n\n`generation.maxConcurrent` is how many generation runs this project may have **open** at once: 20 by\ndefault, settable up to 200 in the jobs config. This blueprint sets 20; raise it to what your plan\nand your runtime can carry:\n\n```ts\njobs: { enabled: true, generation: { maxConcurrent: 50 /* 1\u2013200, default 20 */ } },\n```\n\nAt the cap a new start is refused with `429` and `Retry-After: 5`. **No run is created and nothing\nis held yet.** `request-render` passes the `429` on to the app with `queued: true` (and the\n`item_id` and `request_key`), and leaves the row `pending` with no `run_id`. That render is\n**queued, not refused**: the re-driver will start it, and hold its credits then, up to an hour\nlater. So the app must treat a `429` with `queued: true` as \"queued\" \u2014 show it, watch the row \u2014 and\nmust **never retry it with a new `request_key`**: that is a second render, and both are charged. A\nretry with the **same** `request_key` is always safe. Rows like that are the backlog, and two\nthings drain it:\n\n1. **`redrive-pending`, every minute.** It reads the oldest `pending` rows with no run that are at\n least 30 s old (`{ status: 'pending', created_at: { $lt: \u2026 }, run_id: null }`, sorted by\n `created_at`, 20 per tick; `status` and `created_at` are index slots, so the read stays cheap at\n any size) and starts each one with the same descriptor and the **same idempotency key** as\n `request-render`, so a row the app is re-driving at the same moment still gets one run. It\n **stops at the first `429`**, since the rest of the batch would get the same answer, and the next\n tick carries on. Each try is recorded on the row (`redrive_attempts`, `redriven_at`). A `402`\n fails the row with `insufficient_credits`. A `400`, `401`, `403` or `422` also **stops** the tick\n and fails nothing: every re-driven row sends the same descriptor, so a refusal means the setup is\n wrong (a non-https or private `render_url`, a revoked key), not the row; the tick reports it as\n `stopped: { status, code }`. The only thing that fails a waiting row is age: a row still waiting\n after **one hour** is failed with `not_started: the render waited too long for a free slot`.\n Nothing was ever held, so nothing is refunded. In both the `402` and the one-hour case the owner\n is told once (`Idempotency-Key: render-not-started:<item_id>`), because the last thing they heard\n was \"queued\". (The function's scopes include `notifications:send` for that.)\n2. **The app**, calling `request-render` again with the same `request_key`, if it wants the render\n started sooner than the next tick.\n\n`overlap: 'skip'` keeps a slow tick from being doubled by the next one. On the Free plan, run it\nevery 15 minutes (see the plan note at the top).\n\n**Why it has its own key.** A credit hold placed by a function must name the signed-in user the\nfunction acts for, and a cron tick has none. So `redrive-pending` makes the start with\n`vxil_jobs_key`, an API key holding only `jobs:write`, the way your trusted server would. That is\nmore than \"hold credits and start runs\": `jobs:write` also lets the key cancel or replay any run of\nthis project, enqueue any job, and create or change schedules and flow rules, and it can hold\ncredits against any of your users and start runs against any public https endpoint. Treat it as a\nserver key: keep it only in this function's secrets, never in an app, and rotate it like any\nserver key. The re-driver never reads the\nprice from the row (a signed-in user can edit their own row): the hold is the function's fixed\n`RENDER_CREDITS`, and a row whose `render_key` is not `owner:request_key` is failed, not started.\n(A hand-written row like that which also breaks the `render_key_shape` hook cannot be written at\nall, so the tick counts it as `unwritable` and it keeps one of the 20 slots: fix or delete it by\nhand.)\n\n## A contract test for your runtime\n\nEvery key your runtime posts in the `completed` body is written onto the row, so every key must be a\ndeclared field of `renders`. Keep that true in your own CI with a test beside your runtime's code. It\nfails the day someone adds a key to the completion body and forgets the field:\n\n```ts\n// render-contract.test.ts \u2014 vitest, in YOUR repo (the one holding vxil.config.ts)\nimport { describe, expect, it } from 'vitest';\nimport config from './vxil.config';\n// the bodies your runtime (or coordinator) really posts: import the builders from that code,\n// so the test follows it instead of a hand-copied list\nimport { completedBody, progressBody } from './runtime/callback-bodies';\n\ndescribe('render callbacks', () => {\n const declared = new Set(Object.keys(config.cms!.collections!.renders!.fields!));\n\n it('every key of the completion body is a declared renders field', () => {\n const body = completedBody({ objectId: 'obj_test', durationS: 1 });\n expect(Object.keys(body).filter((k) => !declared.has(k))).toEqual([]);\n });\n\n it('a progress ping carries only the keys the mirror keeps', () => {\n const ping = progressBody({ progress: 40, stage: 'encoding' });\n expect(Object.keys(ping).filter((k) => !['status', 'progress', 'stage', 'message'].includes(k))).toEqual([]);\n });\n});\n```\n\nThe platform also has a fallback for the day that test is missing. When the row refuses a completion\nmirror because of an undeclared key, vxil writes the status alone, so the row still says\n`completed`, and the run reports what it dropped (`GET /v1/jobs/runs/{run_id}` \u2192 `mirror_error`,\nwith `fields_dropped`). The app no longer hangs on `processing`, but the dropped keys are not on the\nrow. The test is what keeps them there.\n\n## The bounds to design against\n\n- **Deadline**: the run's timeout is clamped to `generation.maxTimeoutMs` \u2014 one hour at most. A render\n that can take longer should be split (the next step starts from `notify-ready`), or tracked on your own\n row without a platform-held reserve.\n- **In flight**: `generation.maxConcurrent` renders at once (20 here and by default; up to 200). Over\n it, `request-render` answers `429` and the row waits for `redrive-pending`, or for a retry with the\n same `request_key` ([the backlog](#the-backlog-maxconcurrent-429-and-the-re-driver)).\n- **Holds**: one render's reserve is clamped to `generation.maxReserveCredits` (50 here), and the sum of\n all open holds is capped by `generation.maxOutstandingReserveCredits` (5,000 here).\n- **Callback**: at most 256 KiB per post, JSON, every key a declared field of `renders`.\n\n## Your token, and where it lives\n\n`render_url` and `render_token` are function secrets; `request-render` reads them at invoke time and\nputs them on the generation run, which vxil stores with the run (your project only) for as long as the\njobs retention keeps it. The token is **never returned by a read**: `GET /v1/jobs/runs/{run_id}` (and\nthe dashboard and MCP reads built on it) shows the provider header names with every value as\n`[redacted]`. Rotate it in both places (`vxil secrets set functions/render_token` and your endpoint's\nenv); runs already started keep the token they were started with.\n\n## Evidence\n\n- **Read in vxil's code**: the start request body (`provider.body` + `payload` + `callback_url`), the\n 20-second start bound, the retry ladder, the status mirror writing every completion key onto the row,\n the hold committed on `completed` and released on `failed` / the deadline, and `error` / `hint`\n becoming `error_class` / `error_hint` on `job.generation.failed`.\n- **Read in Trigger.dev's and Modal's documentation, not executed from vxil**: the trigger endpoint\n `POST https://api.trigger.dev/api/v1/tasks/{taskId}/trigger` with `{ payload, options }` and the\n `idempotencyKey` / `ttl` / `tags` / `machine` options; `task({ id, machine, maxDuration, retry, run,\n onFailure })` and `retry` options, `maxDuration` being CPU time per attempt with no `onFailure` when\n it is exceeded, `metadata.set`, and the `ffmpeg()` / `puppeteer()` build extensions; Trigger.dev\n Cloud's US-hosted operational data; Modal's `@modal.fastapi_endpoint` and `.spawn()`. Check each\n vendor's current docs before you ship.\n",
|
|
18235
|
+
"functions": {
|
|
18236
|
+
"notify-ready.ts": "// notify-ready.ts \u2014 job.generation.completed | failed \u2192 tell the render's owner\n// (a vxil function, webhook trigger on `job.generation.`).\n//\n// The status mirror has already written the row (status, and on completion\n// every key your runtime sent back). This function adds the two things a\n// mirror cannot: the failure cause on the row, and a message to the user.\n//\n// AT-LEAST-ONCE: an event can be delivered again. And because the trigger\n// declares `retry: { maxAttempts: 3 }`, a non-2xx answer from here goes back\n// to the jobs ladder for another attempt (without `retry` it would simply be\n// acknowledged). Every attempt carries the same event. The notification carries an\n// Idempotency-Key of one per RUN, so a redelivery sends nothing twice, and the\n// row patch writes the same value again.\n\nimport type { JobGenerationSettledEventPayload, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RenderRow = { owner?: string; composition?: string; output_url?: string; error?: string; redrive_attempts?: number };\n\n/** Platform error classes settle with no hint; these are the ones a render\n * can end with, in words for the row (the class stays first, for code). */\nconst PLATFORM_HINTS: Record<string, string> = {\n GenerationExpired: 'the render did not finish before its deadline',\n RetryableHttp: 'the render endpoint kept failing to accept the render (retries exhausted)',\n NetworkError: 'the render endpoint could not be reached (retries exhausted)',\n};\n\nfunction patchError(base: string, H: Record<string, string>, id: string, error: string): Promise<Response> {\n return fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(id)}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data: { error } }),\n });\n}\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as WebhookFunctionEnvelope<JobGenerationSettledEventPayload>;\n const d = env.payload?.data;\n // job.generation.queued carries no status; a truncated event has no fields\n if (!d || 'truncated' in d || !('status' in d) || !d.generation_id) return Response.json({ skipped: true });\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n const base = env.vxil_base;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n const rowRes = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(d.generation_id)}`, { headers: H });\n // another job's generation event (not a render) \u2014 nothing to do\n if (rowRes.status === 404) return Response.json({ skipped: 'not a render' });\n if (!rowRes.ok) return Response.json({ error: `render read: ${rowRes.status}` }, { status: 502 }); // another attempt (header note)\n const row = ((await rowRes.json()) as { data: { data: RenderRow } }).data.data;\n if (!row.owner) return Response.json({ skipped: 'no owner' });\n\n if (d.status === 'failed') {\n // Not enough credits. A render request-render started itself already\n // answered the user (402) and wrote error: 'insufficient_credits' \u2014 keep\n // that code on the row (write it only if that write was lost) and send\n // nothing. A render the RE-DRIVER started is different: the user last\n // heard 429 \"queued\", so they are told \u2014 with the re-driver's own key\n // (render-not-started:<item_id>), so the two never both send.\n if (d.error_class === 'ReserveInsufficient') {\n if (!row.error) {\n const patched = await patchError(base, H, d.generation_id, 'insufficient_credits');\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n if (!(typeof row.redrive_attempts === 'number' && row.redrive_attempts > 0)) {\n return Response.json({ ok: true, notified: false });\n }\n return send(base, notifications, `render-not-started:${d.generation_id}`, row.owner, {\n subject: 'Your render could not start',\n paragraph: `\"${row.composition ?? 'Your render'}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.`,\n });\n }\n // the cause the run settled with: your runtime's `error` (+ `hint`), or\n // the platform's own class \u2014 which carries no hint, so a short human\n // one is added for the ones a user can meet (PLATFORM_HINTS)\n const hint = d.error_hint ?? (d.error_class ? PLATFORM_HINTS[d.error_class] : undefined);\n const cause = [d.error_class, hint].filter(Boolean).join(': ') || 'render failed';\n if (row.error !== cause) {\n const patched = await patchError(base, H, d.generation_id, cause.slice(0, 500));\n if (!patched.ok) return Response.json({ error: `render patch: ${patched.status}` }, { status: 502 }); // another attempt (header note)\n }\n }\n\n return send(base, notifications, `render-ready:${d.run_id}`, row.owner, d.status === 'completed'\n ? { subject: 'Your render is ready', paragraph: `\"${row.composition ?? 'Your render'}\" finished. Open the app to watch it.` }\n : { subject: 'Your render could not finish', paragraph: 'Nothing was charged \u2014 the credits are back on your balance. Try again in a minute.' });\n },\n};\n\n/** One transactional message; a non-2xx answer asks the ladder for another attempt. */\nasync function send(\n base: string, token: string, idempotencyKey: string, userId: string,\n data: { subject: string; paragraph: string },\n): Promise<Response> {\n const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'idempotency-key': idempotencyKey },\n body: JSON.stringify({ user_id: userId, template: 'transactional', data }),\n });\n return sent.ok\n ? Response.json({ ok: true, notified: true })\n : Response.json({ error: `notifications send: ${sent.status}` }, { status: 502 }); // another attempt (header note)\n}\n",
|
|
18237
|
+
"redrive-pending.ts": "// redrive-pending.ts \u2014 the BACKLOG RE-DRIVER (a vxil function, cron trigger,\n// every minute; `overlap: 'skip'`).\n//\n// cron-walk: drains-filter \u2014 every row it starts is PATCHed with its run_id (and\n// every row it gives up on to status 'failed'), which takes it out of the\n// `{ status: 'pending', run_id: null }` read; a 429 stops the tick early.\n//\n// Why it exists: at the generation concurrency cap (`generation.maxConcurrent`\n// open runs \u2014 20 in this blueprint, up to 200) request-render answers 429 and\n// leaves the row `pending` with NO run. Without this function only the caller\n// re-drives it (the same request_key again). With it, a backlog drains on its\n// own: each tick reads the oldest pending rows that have had no run for at\n// least REDRIVE_AFTER_MS, and starts each one with the SAME descriptor and the\n// SAME idempotency key (the row's render_key) request-render uses \u2014 so a row a\n// user is re-driving at the same moment still gets exactly one run.\n//\n// per row, by the jobs answer:\n// 202 (new run, or an open one handed back) \u2192 PATCH run_id (the status\n// mirror settles the row from here)\n// 202 deduplicated, generation_status 'failed' \u2192 PATCH run_id + status 'failed'\n// 402 (too few credits) \u2192 PATCH status 'failed',\n// error 'insufficient_credits',\n// and TELL the owner (they last\n// heard \"queued\", not \"refused\")\n// 429 (cap reached / too many credits held) \u2192 STOP the tick; the rest wait\n// for the next one (Retry-After\n// is reported)\n// 400 / 401 / 403 / 422 \u2192 STOP the tick, report it. Every\n// re-driven row sends the same\n// descriptor apart from its own\n// (already bounded) composition and\n// props, so a refusal is a SETUP\n// error \u2014 a non-https or private\n// render_url, a wrong or revoked\n// key, a config change \u2014 that\n// would fail every row the same\n// way. Nothing is failed for it:\n// fix the setup and the next tick\n// carries on.\n// 5xx / network \u2192 record the attempt, go on\n// A row still pending with no run after BACKLOG_MAX_AGE_MS is the ONLY thing\n// the re-driver gives up on: status 'failed', error 'not_started: \u2026' (nothing\n// was ever held for it), and the owner is told once\n// (Idempotency-Key render-not-started:<item_id>).\n// Every try is recorded on the row: redrive_attempts, redriven_at.\n//\n// A HAND-WRITTEN ROW that breaks render_key_shape (written before the hook\n// existed, or by a path that bypassed it) cannot be patched either \u2014 the\n// hook judges the merged row \u2014 so it would stay pending and take one of the\n// tick's slots for good. Fix or delete such rows by hand; the tick reports\n// them as `unwritable`.\n\n// THE KEY: a function-originated credit hold must name the user it acts for,\n// and a cron tick has no signed-in user \u2014 so the jobs call is made with\n// `vxil_jobs_key`, an API key of this same backend holding ONLY `jobs:write`\n// (a trusted server key may hold credits for any of your users; README \"The\n// backlog\"). The rows are read and written with the function's own scoped cms\n// token. The credits held are this blueprint's fixed price, never the row's\n// `credits` field \u2014 a signed-in user can edit their own row, so nothing the\n// re-driver charges or starts is read from a value they could have lowered.\n// The owner is told with the function's scoped notifications token.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\n/** What one render costs \u2014 the SAME number as request-render's RENDER_CREDITS. */\nconst RENDER_CREDITS = 5;\n/** The deadline \u2014 the SAME number as request-render's RENDER_DEADLINE_MS. */\nconst RENDER_DEADLINE_MS = 1_800_000;\n/** A row is the re-driver's only once request-render has had time to start it. */\nconst REDRIVE_AFTER_MS = 30_000;\n/** Rows started per tick (bounded: a tick is one function invocation). */\nconst MAX_REDRIVE_PER_TICK = 20;\n/** A row still waiting for a run after this long is failed (nothing is held). */\nconst BACKLOG_MAX_AGE_MS = 3_600_000;\n\ntype Env = CronFunctionEnvelope;\ntype RenderRow = {\n item_id: string;\n created_at?: string;\n data: {\n render_key?: string; request_key?: string; owner?: string; composition?: string;\n props?: Record<string, unknown>; created_at?: string; redrive_attempts?: number;\n };\n};\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const notifications = env.scoped_jwts?.notifications;\n const jobsKey = env.secrets?.vxil_jobs_key;\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!cms || !notifications) return Response.json({ error: 'missing cms/notifications scope' }, { status: 403 });\n if (!jobsKey || !renderUrl || !renderToken) {\n return Response.json(\n { error: 'store the secrets: vxil secrets set functions/vxil_jobs_key (an API key holding only jobs:write), functions/render_url, functions/render_token' },\n { status: 503 },\n );\n }\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n const now = Date.now();\n\n // the oldest pending rows with no run: `status` (s4) and `created_at` (t1)\n // are index slots, so the slots pick the rows and the `run_id: null` test\n // only runs over what they picked\n const filter = encodeURIComponent(JSON.stringify({\n status: 'pending',\n created_at: { $lt: new Date(now - REDRIVE_AFTER_MS).toISOString() },\n run_id: null,\n }));\n const listed = await fetch(\n `${base}/v1/cms/items/renders?filter=${filter}&sort=created_at&limit=${MAX_REDRIVE_PER_TICK}`,\n { headers: H },\n );\n if (!listed.ok) return Response.json({ error: `renders read: ${listed.status}` }, { status: 502 });\n const rows = ((await listed.json()) as { data?: { items?: RenderRow[] } }).data?.items ?? [];\n\n const out = { scanned: rows.length, started: 0, failed: 0, retry_later: 0, given_up: 0, unwritable: 0,\n notified: 0, notify_failed: 0,\n stopped: null as null | { status: number; code: string | null; retry_after: string | null } };\n const tell = async (row: RenderRow, why: 'credits' | 'waited') => {\n if (await notifyNotStarted(base, notifications, row, why)) out.notified += 1;\n else out.notify_failed += 1;\n };\n\n for (const row of rows) {\n const d = row.data;\n const attempts = (typeof d.redrive_attempts === 'number' ? d.redrive_attempts : 0) + 1;\n const createdAt = Date.parse(d.created_at ?? row.created_at ?? '');\n const mark = { redrive_attempts: attempts, redriven_at: new Date(now).toISOString() };\n\n // a row that cannot be a render request-render made (a hand-written row\n // missing its key parts), or one that has waited too long: fail it \u2014 no\n // run exists, so nothing is held and nothing is refunded\n const shapeOk = !!d.owner && !!d.request_key && !!d.composition\n && d.render_key === `${d.owner}:${d.request_key}`\n && d.composition.length <= 120 && JSON.stringify(d.props ?? {}).length <= 16_384;\n if (!shapeOk || (Number.isFinite(createdAt) && now - createdAt > BACKLOG_MAX_AGE_MS)) {\n const error = shapeOk\n ? 'not_started: the render waited too long for a free slot'\n : 'not_started: the row is not a render request-render created';\n // only while it is STILL pending with no run (a racing start wins)\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error }, { status: 'pending', run_id: null })) {\n out.given_up += 1;\n // the owner last heard \"queued\" (a 429): say it will not happen. A\n // malformed row's owner is not trusted, so it is only failed.\n if (shapeOk) await tell(row, 'waited');\n } else if (!shapeOk) {\n out.unwritable += 1; // header note: fix it by hand\n }\n continue;\n }\n\n const enq = await startRun(base, jobsKey, renderUrl, renderToken, {\n itemId: row.item_id, user: d.owner!, renderKey: d.render_key!, requestKey: d.request_key!,\n composition: d.composition!, props: d.props ?? {},\n });\n if (!enq) { // network: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n if (enq.status === 402) {\n if (await patchRow(base, H, row.item_id, { ...mark, status: 'failed', error: 'insufficient_credits' })) await tell(row, 'credits');\n out.failed += 1;\n continue;\n }\n if (enq.status === 429 || (enq.status >= 400 && enq.status < 500)) {\n // 429: at the cap \u2014 the rest of the batch would get the same answer.\n // 400 / 401 / 403 / 422: a setup error (header note) \u2014 failing rows for\n // it would be wrong and could not be undone. Either way: record the\n // try, stop, and let the next tick (Retry-After: seconds) go on.\n await patchRow(base, H, row.item_id, mark);\n const code = enq.status === 429 ? null\n : ((await enq.json().catch(() => ({}))) as { error?: { code?: string } }).error?.code ?? null;\n out.stopped = { status: enq.status, code, retry_after: enq.headers.get('retry-after') };\n break;\n }\n if (!enq.ok) { // 5xx: try again next tick\n await patchRow(base, H, row.item_id, mark);\n out.retry_later += 1;\n continue;\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // the run already ENDED (its row write was lost): record it as failed\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id, status: 'failed' });\n out.failed += 1;\n continue;\n }\n await patchRow(base, H, row.item_id, { ...mark, run_id: run.run_id });\n out.started += 1;\n }\n // a 2xx either way: a cron tick is never retried (the next tick is the retry)\n return Response.json(out);\n },\n};\n\ninterface Start {\n itemId: string; user: string; renderKey: string; requestKey: string;\n composition: string; props: Record<string, unknown>;\n}\n\n/** The SAME webhook-mode generation request-render starts (the CI gate keeps\n * the two descriptors equal), sent with the jobs:write server key. Null on a\n * network error. */\nasync function startRun(base: string, key: string, renderUrl: string, renderToken: string, a: Start): Promise<Response | null> {\n return fetch(`${base}/v1/jobs/generation`, {\n // tenant-key: jobs:write via secret:vxil_jobs_key \u2014 called with the server\n // key, not the function's scoped token (header note); the CI gate checks\n // the secret is declared instead of a jobs scope\n method: 'POST',\n headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: renderUrl,\n method: 'POST',\n headers: { authorization: `Bearer ${renderToken}` },\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n payload: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\n idempotency_key: a.renderKey,\n }),\n }).catch(() => null);\n}\n\n/** PATCH a render row (optionally only while `when` still holds); true when it landed. */\nasync function patchRow(\n base: string, H: Record<string, string>, itemId: string,\n data: Record<string, unknown>, when?: Record<string, unknown>,\n): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/renders/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: H,\n body: JSON.stringify(when ? { data, if: when } : { data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n\n/** Tell the owner a render they were told was queued will not start. One\n * message per render (Idempotency-Key render-not-started:<item_id> \u2014 the SAME\n * key notify-ready uses for a re-driven credit refusal, so the two never both\n * send). True when it was accepted. */\nasync function notifyNotStarted(base: string, token: string, row: RenderRow, why: 'credits' | 'waited'): Promise<boolean> {\n const name = row.data.composition ?? 'Your render';\n const res = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`, 'content-type': 'application/json',\n 'idempotency-key': `render-not-started:${row.item_id}`,\n },\n body: JSON.stringify({\n user_id: row.data.owner,\n template: 'transactional',\n data: why === 'credits'\n ? { subject: 'Your render could not start', paragraph: `\"${name}\" was queued, but there were not enough credits when its turn came. Nothing was charged \u2014 top up and try again.` }\n : { subject: 'Your render could not start', paragraph: `\"${name}\" waited over an hour for a free slot and was cancelled. Nothing was charged \u2014 try again later.` },\n }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n",
|
|
18238
|
+
"request-render.ts": "// request-render.ts \u2014 start ONE long render for the signed-in user (a vxil\n// function, http trigger, END-USER mode).\n//\n// POST /v1/fn/request-render (with the user's session)\n// { \"composition\": \"promo-30s\", \"request_key\": \"<your idempotency key>\", \"props\": { \u2026 } }\n// \u2192 202 { item_id, run_id, credits } a new render, credits held\n// \u2192 200 { duplicate: true, request_key, item_id, run_id, status }\n// this user's request_key already started one\n// (or its run has already ended: status 'failed')\n// \u2192 402 { error: 'insufficient_credits', item_id } nothing held; use a new request_key\n// \u2192 429 / 502 { error, queued: true, item_id, request_key, retry_after }\n// no run YET: the row is queued and\n// redrive-pending starts it (credits\n// held then). Never retry with a NEW\n// request_key \u2014 that is a second render.\n//\n// What happens after the 202 is vxil's and your runtime's, not this function's:\n// \u2022 the generation lane POSTs your render endpoint (secret render_url) with\n// `Authorization: Bearer <render_token>` and the JSON body\n// { render_id, user_id, composition, props,\n// payload: { generation_id, correlation_id, deadline_at }, callback_url }\n// (plus an X-Vxil-Jobs-Signature header). The endpoint must answer 2xx\n// within 20 s \u2014 queue the work, do not render inline. A 408 / 429 / 5xx or\n// a timeout is retried; any other 4xx ends the run and refunds the credits.\n// \u2022 your runtime POSTs JSON to callback_url: { \"status\": \"processing\", \u2026 }\n// while it works, then { \"status\": \"completed\", \"output_url\": \"\u2026\",\n// \"duration_s\": 31.2, \"progress\": 100, \"stage\": \"done\" } \u2014 or\n// { \"status\": \"failed\", \"error\": \"render_failed\", \"hint\": \"\u2026\" }.\n// \u2022 vxil settles: completed COMMITS the held credits and writes every key of\n// the body onto this render's row; failed, no answer by the deadline, or a\n// cancel REFUNDS them and the row says failed. `deadline_at` (ISO time) is\n// when vxil stops waiting: a runtime that cannot finish by then should post\n// `failed` itself and stop \u2014 a `completed` after it changes nothing (the\n// credits are already back and the row says failed).\n// Every run ends with job.generation.completed | failed (generation_id = the\n// row's item_id, correlation_id = its request_key) \u2014 notify-ready listens.\n//\n// DELIVERY IS AT-LEAST-ONCE and users double-tap: the row's render_key\n// (owner + ':' + request_key \u2014 per user) is unique, so a second start with the\n// same key is a 409. On a 409 we read THIS user's row: a row that already has\n// its run is a duplicate; a row with no run (the first start died between the\n// row and the enqueue, or lost the enqueue's answer) is RE-DRIVEN \u2014 the run\n// carries the same render_key as its idempotency_key, so jobs hands back the\n// existing run instead of starting a second render.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Input = { composition?: string; request_key?: string; props?: Record<string, unknown> };\ntype Env = HttpFunctionEnvelope<Input>;\ntype RenderRow = {\n item_id: string;\n data: { composition?: string; props?: Record<string, unknown>; run_id?: string; status?: string };\n};\n\n/** What one render costs, in `render_credits`. Price by composition if yours\n * differ \u2014 the per-run hold is clamped to `generation.maxReserveCredits`. */\nconst RENDER_CREDITS = 5;\n/** The deadline: a render your runtime never reports on fails and refunds\n * after this long. At most one hour (`generation.maxTimeoutMs`). */\nconst RENDER_DEADLINE_MS = 1_800_000;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n const jobs = env.scoped_jwts?.jobs;\n if (!cms || !jobs) return Response.json({ error: 'missing cms/jobs scope' }, { status: 403 });\n const renderUrl = env.secrets?.render_url;\n const renderToken = env.secrets?.render_token;\n if (!renderUrl || !renderToken) {\n return Response.json(\n { error: 'store your render endpoint: vxil secrets set functions/render_url and functions/render_token' },\n { status: 500 },\n );\n }\n // the held credits are FORCED onto the verified end-user\n const user = env.end_user?.id;\n if (!user) return Response.json({ error: \"invoke request-render with the user's session (end-user mode)\" }, { status: 401 });\n\n const composition = typeof env.payload?.composition === 'string' ? env.payload.composition.trim().slice(0, 120) : '';\n const requestKey = typeof env.payload?.request_key === 'string' ? env.payload.request_key.slice(0, 120) : '';\n const rawProps = env.payload?.props;\n const props = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? rawProps : {};\n if (!composition || !requestKey) {\n return Response.json({ error: 'need { composition, request_key, props? }' }, { status: 422 });\n }\n if (JSON.stringify(props).length > 16_384) {\n return Response.json({ error: 'props too large (16 KB max) \u2014 pass a reference to your own storage instead' }, { status: 413 });\n }\n const renderKey = `${user}:${requestKey}`;\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // 1. create-or-find the row the app watches (owned by the user \u2014 cms forces\n // `owner` in end-user mode; the render_key_shape hook checks the key)\n const created = await fetch(`${base}/v1/cms/items/renders`, {\n method: 'POST',\n headers: H,\n body: JSON.stringify({\n data: {\n render_key: renderKey, request_key: requestKey, owner: user, composition, props,\n credits: RENDER_CREDITS, status: 'pending', created_at: new Date().toISOString(),\n },\n }),\n });\n if (created.status === 409) {\n // THIS user's row for this key (the read is owner-scoped in end-user mode)\n const filter = encodeURIComponent(JSON.stringify({ render_key: renderKey }));\n const found = await fetch(`${base}/v1/cms/items/renders?filter=${filter}&limit=1`, { headers: H });\n const row = found.ok ? ((await found.json()) as { data?: { items?: RenderRow[] } }).data?.items?.[0] : undefined;\n if (!row) return Response.json({ error: `render lookup: ${found.status}` }, { status: 502 });\n if (row.data.run_id || row.data.status === 'failed') {\n return Response.json({\n duplicate: true, request_key: requestKey, item_id: row.item_id,\n run_id: row.data.run_id ?? null, status: row.data.status ?? 'pending',\n });\n }\n // a start that never got its run: re-drive it (jobs dedupes on render_key)\n return startRun({\n base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey,\n itemId: row.item_id, composition: row.data.composition ?? composition, props: row.data.props ?? props,\n });\n }\n if (!created.ok) return Response.json({ error: `render row: ${created.status}` }, { status: 502 });\n const itemId = ((await created.json()) as { data: { item_id: string } }).data.item_id;\n return startRun({ base, H, jobs, renderUrl, renderToken, user, renderKey, requestKey, itemId, composition, props });\n },\n};\n\ninterface StartArgs {\n base: string; H: Record<string, string>; jobs: string; renderUrl: string; renderToken: string;\n user: string; renderKey: string; requestKey: string; itemId: string;\n composition: string; props: Record<string, unknown>;\n}\n\n/** 2. the webhook-mode generation run: your endpoint, the held credits, the\n * deadline and the status mirror. Idempotent on render_key: a re-drive gets\n * the run that already exists. */\nasync function startRun(a: StartArgs): Promise<Response> {\n const enq = await fetch(`${a.base}/v1/jobs/generation`, {\n method: 'POST',\n headers: { authorization: `Bearer ${a.jobs}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n job_name: 'render',\n provider: {\n url: a.renderUrl,\n method: 'POST',\n // stored with the run for your project only, shown as [redacted] on\n // every run read, sent to your endpoint on the start call\n headers: { authorization: `Bearer ${a.renderToken}` },\n // your endpoint receives this, plus `payload` and `callback_url`\n // (user_id: the owner a coordinator uploads the output file for)\n body: { render_id: a.itemId, user_id: a.user, composition: a.composition, props: a.props },\n },\n completion: { mode: 'webhook', status_path: 'status' },\n // the status word onto `status`, and a `processing` ping's progress /\n // stage / message onto the same-named fields of the row\n status_mirror: {\n feature: 'cms', collection: 'renders', record_id: a.itemId, column: 'status',\n progress_fields: ['progress', 'stage', 'message'],\n },\n reserve_credits: { amount: RENDER_CREDITS, user_id: a.user, credit_type: 'render_credits', reason: `render ${a.composition}` },\n timeout: { after_ms: RENDER_DEADLINE_MS },\n // rides job.generation.* as generation_id / correlation_id, and reaches\n // your endpoint beside callback_url. deadline_at = when vxil stops\n // waiting (the deadline counts from this enqueue; a re-drive gets the\n // first run back, with ITS payload).\n payload: {\n generation_id: a.itemId, correlation_id: a.requestKey,\n deadline_at: new Date(Date.now() + RENDER_DEADLINE_MS).toISOString(),\n },\n idempotency_key: a.renderKey,\n }),\n });\n if (enq.status === 402) {\n // not enough credits: the run already ENDED (job.generation.failed,\n // ReserveInsufficient) and nothing was held. This key is spent; a new\n // attempt (after a top-up) uses a new request_key.\n const marked = await patchRow(a, { status: 'failed', error: 'insufficient_credits' });\n // if that write failed the row still says pending with no run; a retry with\n // the same key re-drives, gets the ended run back and marks it failed then\n return Response.json(\n { error: 'insufficient_credits', item_id: a.itemId, ...(marked ? {} : { row_updated: false }) },\n { status: 402 },\n );\n }\n if (!enq.ok) {\n // 429 (too many in flight) / 5xx: no run YET \u2014 the row stays pending with\n // no run, and it is QUEUED: redrive-pending (cron) starts it once a slot\n // frees up (within the hour, or the row is failed and the owner told) and\n // holds the credits then. `queued: true` says so. The app must NOT retry\n // with a NEW request_key (that is a second render, charged twice): show\n // \"queued\", watch the row, and retry only with the SAME request_key.\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, queued: true, item_id: a.itemId, request_key: a.requestKey, retry_after: enq.headers.get('retry-after') },\n { status: enq.status === 429 ? 429 : 502 },\n );\n }\n const run = ((await enq.json()) as { data: { run_id: string; generation_status?: string; deduplicated?: boolean } }).data;\n if (run.deduplicated && run.generation_status === 'failed') {\n // a re-drive whose run already ENDED (refused for credits, failed or timed\n // out, and the row write that said so was lost): record it and say so \u2014\n // never report a fresh render with credits held\n const marked = await patchRow(a, { run_id: run.run_id, status: 'failed' });\n return Response.json({\n duplicate: true, request_key: a.requestKey, item_id: a.itemId, run_id: run.run_id, status: 'failed',\n ...(marked ? {} : { row_updated: false }),\n });\n }\n const linked = await patchRow(a, { run_id: run.run_id });\n // the run exists either way (and settles the row through the status mirror);\n // an unlinked row is linked by the next same-key call\n return Response.json(\n { item_id: a.itemId, run_id: run.run_id, credits: RENDER_CREDITS, ...(linked ? {} : { row_updated: false }) },\n { status: 202 },\n );\n}\n\n/** PATCH this render's row; true when the write landed. */\nasync function patchRow(a: StartArgs, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${a.base}/v1/cms/items/renders/${encodeURIComponent(a.itemId)}`, {\n method: 'PATCH',\n headers: a.H,\n body: JSON.stringify({ data }),\n }).catch(() => undefined);\n return res?.ok ?? false;\n}\n"
|
|
18239
|
+
}
|
|
18240
|
+
},
|
|
18116
18241
|
{
|
|
18117
18242
|
"id": "research-library",
|
|
18118
18243
|
"title": "Research Library",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"miniflare": "^4.20260611.0",
|
|
49
|
-
"@vxil/config": "0.
|
|
50
|
-
"@vxil/feature-configs": "0.
|
|
51
|
-
"@vxil/
|
|
52
|
-
"@vxil/
|
|
49
|
+
"@vxil/config": "0.9.0",
|
|
50
|
+
"@vxil/feature-configs": "0.8.0",
|
|
51
|
+
"@vxil/runtime": "0.0.1",
|
|
52
|
+
"@vxil/types": "0.0.1"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "pnpm --filter @vxil/feature-configs run build && pnpm --filter @vxil/config run build && esbuild bin/vxil.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/vxil.js --banner:js='#!/usr/bin/env node' --external:esbuild --external:miniflare && esbuild src/config-entry.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/config.js && pnpm exec tsx scripts/build-config-dts.ts",
|