@vxil/cli 0.13.2 → 0.14.0
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.0";
|
|
4525
|
+
var VXIL_CLI_PKG_VERSION = "0.14.0";
|
|
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) and progress (the latest { progress 0..100, stage, message, at } report, 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,33 @@ 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, and the owner is told once per run.",
|
|
18216
|
+
"collections": [
|
|
18217
|
+
"renders"
|
|
18218
|
+
],
|
|
18219
|
+
"features": [
|
|
18220
|
+
"jobs",
|
|
18221
|
+
"payments",
|
|
18222
|
+
"cms",
|
|
18223
|
+
"notifications",
|
|
18224
|
+
"functions"
|
|
18225
|
+
],
|
|
18226
|
+
"hasFunctions": true,
|
|
18227
|
+
"byoKeys": [
|
|
18228
|
+
"render_url",
|
|
18229
|
+
"render_token"
|
|
18230
|
+
],
|
|
18231
|
+
"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, 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//\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\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, and the row would keep\n // saying `processing` while the run has completed).\n output_url: { type: 'text' },\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\n error: { type: 'text' },\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 // 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 },\n});\n",
|
|
18232
|
+
"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\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\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.\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` and sends nothing.\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, composition, props, payload: { generation_id, correlation_id, deadline_at }, callback_url }`. 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 at most 256 KiB, **every key a declared field of `renders`** (add a field before you send a new key). 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); vxil stores the keys, not the file.\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## 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 | ended at once (`ReserveInsufficient`), never started | `failed`, `error: insufficient_credits`; that `request_key` is spent | nothing held |\n| too many renders in flight, or a jobs-side fault | not created (the caller gets `429` with `retry_after`, or `502`) | `pending`, no run \u2014 calling again with the **same** `request_key` re-drives it | nothing held |\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; up to 200). Over it, `request-render`\n answers `429` and the row waits for a retry with the same `request_key`.\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',
|
|
18233
|
+
"functions": {
|
|
18234
|
+
"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 };\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: request-render already answered the user (402)\n // and wrote error: 'insufficient_credits' \u2014 keep that code on the row\n // (write it only if that write was lost), and send no e-mail for a\n // render that never started.\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 return Response.json({ ok: true, notified: false });\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 const sent = await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${notifications}`,\n 'content-type': 'application/json',\n 'idempotency-key': `render-ready:${d.run_id}`,\n },\n body: JSON.stringify({\n user_id: row.owner,\n template: 'transactional',\n data: 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 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};\n",
|
|
18235
|
+
"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//\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, 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 body: { render_id: a.itemId, 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 is promised \u2014 the row stays\n // pending with no run, so a retry with the SAME request_key re-drives it\n return Response.json(\n { error: `generation enqueue: ${enq.status}`, item_id: a.itemId, 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"
|
|
18236
|
+
}
|
|
18237
|
+
},
|
|
18116
18238
|
{
|
|
18117
18239
|
"id": "research-library",
|
|
18118
18240
|
"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.0",
|
|
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/
|
|
49
|
+
"@vxil/config": "0.9.0",
|
|
50
|
+
"@vxil/runtime": "0.0.1",
|
|
51
51
|
"@vxil/types": "0.0.1",
|
|
52
|
-
"@vxil/
|
|
52
|
+
"@vxil/feature-configs": "0.8.0"
|
|
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",
|