@civitai/blocks-react 0.77.0 → 0.78.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.
package/README.md CHANGED
@@ -1229,6 +1229,67 @@ if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
1229
1229
  if (!loading && pack) console.log(Object.keys(pack.lists));
1230
1230
  ```
1231
1231
 
1232
+ ### `useResourceFile(modelVersionId, fileId?)`
1233
+
1234
+ Read a resource file's parsed JSON — for a workflow, its graphs — by model
1235
+ version. The host resolves, fetches, unzips and parses the file **in the
1236
+ viewer's own page session**, enforcing the real download gates, so the iframe
1237
+ never sees the session, a signed URL or the raw bytes. No block scope is needed.
1238
+ Returns `{ file, loading, error, refetch }`.
1239
+
1240
+ - `file.meta` names what was served (`modelId`, `modelVersionId`, `fileId`,
1241
+ `fileName`, `modelName`, `versionName`, `creatorUsername`).
1242
+ - `file.entries` holds one `{ path, format, graph }` per JSON document: one for a
1243
+ plain `.json`, several for a `.zip` (non-JSON entries are skipped). `format`
1244
+ is `'ui'` (a `nodes` array), `'api'` (every top-level value has `class_type`
1245
+ and `inputs`) or `'unknown'`; check it before treating `graph` as a graph.
1246
+ `file.truncated` is `true` when an entry was dropped: past 20 entries, over
1247
+ the 4 MB per-entry or 16 MB total cap, unreadable, or not valid JSON.
1248
+ - Pass `fileId` to pin one file of a multi-file version. Without it the host
1249
+ picks one.
1250
+ - Changing `modelVersionId` or `fileId` resets `file` and `error` to `null`
1251
+ before the new fetch, so a file never shows for ids it does not belong to.
1252
+ - The request waits up to 120 s, because the host downloads and unzips the file.
1253
+
1254
+ On failure `error` is a `ResourceFileError` whose `code` is one of a closed set:
1255
+
1256
+ | `code` | Means | Retry? |
1257
+ |---|---|---|
1258
+ | `not-found` | No such version or file, not a workflow, or not downloadable for free (see below) | No |
1259
+ | `forbidden` | Above the viewer's maturity ceiling on this site, or the app is in review preview | No |
1260
+ | `unauthorized` | The viewer is signed out | No |
1261
+ | `too-large` | Over 32 MB, or every entry was over a size cap (4 MB per entry, 16 MB in total) | No |
1262
+ | `parse-failed` | Not a `.json`/`.zip`, corrupt, or no valid JSON — **or** the host's catch-all: a network error, a failed fetch, no answer within its 110 s deadline, a transient storage failure, or another server error | Once, maybe: it can be transient |
1263
+ | `busy` | Three fetches already in flight in this tab | Yes |
1264
+ | `rate-limited` | The viewer's 30-a-minute budget is spent | Yes, later |
1265
+
1266
+ 🔴 **Full-page app hosts only — not the model-page slot, not `dev:live`.** The
1267
+ model-page slot does not answer it (the request times out), and `dev:live`
1268
+ replies `parse-failed`. Develop against `dev:mock`: `createMockHost` takes a
1269
+ `resourceFile` (the canned reply) and a `resourceFileError` (force a code);
1270
+ `setScenario({ resourceFileError: undefined })` clears the forced error.
1271
+
1272
+ 🔴 **Only free, public versions are served.** An unpublished draft (the owner's
1273
+ included) and an early-access or paid version answer `not-found` — even for a
1274
+ viewer who bought it. Only the owner gets their own published early-access
1275
+ version. A POI model, or a model or version whose availability is not `Public`
1276
+ or `Unsearchable` (e.g. `Private`), is refused to everyone but the owner,
1277
+ moderators included.
1278
+
1279
+ ```tsx
1280
+ const { file, loading, error, refetch } = useResourceFile(modelVersionId);
1281
+ // Retry a retryable code from an effect, after a delay — never during render.
1282
+ const retryable =
1283
+ error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
1284
+ useEffect(() => {
1285
+ if (!retryable) return;
1286
+ const t = setTimeout(refetch, 2_000);
1287
+ return () => clearTimeout(t);
1288
+ }, [retryable, refetch]);
1289
+ const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
1290
+ if (!loading && file) console.log(file.meta.fileName, graphs.length, file.truncated);
1291
+ ```
1292
+
1232
1293
  ### `useCollectionFollow()`
1233
1294
 
1234
1295
  Follow / unfollow a collection **for the viewer**, host-mediated over
@@ -3830,6 +3891,7 @@ Runnable, minimal blocks — one per feature, each with its own README:
3830
3891
 
3831
3892
  | `@civitai/blocks-react` | pairs with `@civitai/app-sdk` | adds |
3832
3893
  |---|---|---|
3894
+ | `0.78.x` | `^0.79.0` | `useResourceFile()` (`GET_RESOURCE_FILE`). Its types (`BlockResourceFile` and friends) come from the SDK's `0.79.0`; the hook imports no SDK value, so the declared peer floor is unchanged. |
3833
3895
  | `0.50.x` | `^0.40.0` | `useCreatePostFromApp()` (`CREATE_POST_FROM_APP`) + the `posts:write:self` scope. Pairing `0.50.x` with an SDK below `0.40.0` fails at `tsc` with `Cannot find name 'BlockCreatePostHostError'`, not at install. |
3834
3896
  | `0.48.x` | `^0.38.0` | `useCollectionFollow()` + `<FollowButton>` / `<TipButton>` (`SET_COLLECTION_FOLLOW`). Pairing `0.48.x` with an SDK below `0.38.0` fails at `tsc` with `Cannot find name 'BlockCollectionFollowErrorCode'`, not at install — the floor those releases declared was deliberately wide (#206 — a per-minor floor forced a major on consumers). |
3835
3897
  | `0.36.x` | `^0.27.0` | auto-installs the SDK's opaque-origin web-storage shim (`@civitai/app-sdk/safe-storage`) on import |
@@ -0,0 +1,71 @@
1
+ import type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta } from '@civitai/app-sdk/blocks';
2
+ export type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta, };
3
+ /**
4
+ * A resource-file read failure. The host returns one code from a CLOSED set, and
5
+ * this error carries it on `.code` so a block can branch on the exact reason:
6
+ * `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
7
+ * `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
8
+ * the code.
9
+ */
10
+ export declare class ResourceFileError extends Error {
11
+ readonly code: BlockResourceFileErrorCode;
12
+ constructor(code: BlockResourceFileErrorCode);
13
+ }
14
+ /**
15
+ * What {@link useResourceFile} returns.
16
+ */
17
+ export interface UseResourceFile {
18
+ /**
19
+ * The parsed file for the CURRENT `modelVersionId` / `fileId`, or `null` until
20
+ * it loads. Reset to `null` the moment either id changes, so a file is never
21
+ * shown for an id it does not belong to. A `refetch()` of the same ids keeps it
22
+ * while reloading.
23
+ */
24
+ file: BlockResourceFile | null;
25
+ /** `true` while a fetch (initial or `refetch`) is in flight. */
26
+ loading: boolean;
27
+ /**
28
+ * The last fetch's failure as a {@link ResourceFileError} (`.code` is the
29
+ * reason), or a plain `Error` (a `RequestTimeoutError`) when the host never
30
+ * answered. Cleared at the start of the next fetch, and when either id changes.
31
+ */
32
+ error: Error | null;
33
+ /** Re-request the file. */
34
+ refetch: () => void;
35
+ }
36
+ /**
37
+ * Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
38
+ * id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
39
+ * bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
40
+ * and parses the file in the viewer's own page session, enforcing the real
41
+ * download gates, and returns only the parsed JSON. The iframe never sees the
42
+ * session, the signed URL or the raw bytes.
43
+ *
44
+ * Pass `fileId` to pin one file of a multi-file version; without it the host
45
+ * picks one. Only free, public versions are served: an unpublished draft or an
46
+ * early-access / paid version answers `not-found`, even for a viewer who bought
47
+ * it. Full-page app hosts only — the model-page slot does not answer it, and
48
+ * `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
49
+ *
50
+ * Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
51
+ * clears `file` and `error` first. A non-positive or non-integer
52
+ * `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
53
+ * up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
54
+ * unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
55
+ * after that. Only the LATEST request may write state: a superseded or
56
+ * post-unmount reply is dropped (#392).
57
+ *
58
+ * @example
59
+ * const { file, loading, error, refetch } = useResourceFile(modelVersionId);
60
+ * // Retry a retryable code from an effect, after a delay — never during render.
61
+ * const retryable =
62
+ * error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
63
+ * useEffect(() => {
64
+ * if (!retryable) return;
65
+ * const t = setTimeout(refetch, 2_000);
66
+ * return () => clearTimeout(t);
67
+ * }, [retryable, refetch]);
68
+ * const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
69
+ */
70
+ export declare function useResourceFile(modelVersionId: number, fileId?: number): UseResourceFile;
71
+ //# sourceMappingURL=useResourceFile.d.ts.map
@@ -0,0 +1,113 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { RESOURCE_FILE_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
+ import { useRequestSequencer } from './useRequestSequencer.js';
6
+ import { useTransportConnection } from './useTransportConnection.js';
7
+ /**
8
+ * A resource-file read failure. The host returns one code from a CLOSED set, and
9
+ * this error carries it on `.code` so a block can branch on the exact reason:
10
+ * `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
11
+ * `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
12
+ * the code.
13
+ */
14
+ export class ResourceFileError extends Error {
15
+ code;
16
+ constructor(code) {
17
+ super(code);
18
+ this.name = 'ResourceFileError';
19
+ this.code = code;
20
+ }
21
+ }
22
+ /**
23
+ * Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
24
+ * id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
25
+ * bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
26
+ * and parses the file in the viewer's own page session, enforcing the real
27
+ * download gates, and returns only the parsed JSON. The iframe never sees the
28
+ * session, the signed URL or the raw bytes.
29
+ *
30
+ * Pass `fileId` to pin one file of a multi-file version; without it the host
31
+ * picks one. Only free, public versions are served: an unpublished draft or an
32
+ * early-access / paid version answers `not-found`, even for a viewer who bought
33
+ * it. Full-page app hosts only — the model-page slot does not answer it, and
34
+ * `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
35
+ *
36
+ * Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
37
+ * clears `file` and `error` first. A non-positive or non-integer
38
+ * `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
39
+ * up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
40
+ * unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
41
+ * after that. Only the LATEST request may write state: a superseded or
42
+ * post-unmount reply is dropped (#392).
43
+ *
44
+ * @example
45
+ * const { file, loading, error, refetch } = useResourceFile(modelVersionId);
46
+ * // Retry a retryable code from an effect, after a delay — never during render.
47
+ * const retryable =
48
+ * error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
49
+ * useEffect(() => {
50
+ * if (!retryable) return;
51
+ * const t = setTimeout(refetch, 2_000);
52
+ * return () => clearTimeout(t);
53
+ * }, [retryable, refetch]);
54
+ * const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
55
+ */
56
+ export function useResourceFile(modelVersionId, fileId) {
57
+ useTransportConnection();
58
+ const [file, setFile] = useState(null);
59
+ const [loading, setLoading] = useState(true);
60
+ const [error, setError] = useState(null);
61
+ const seq = useRequestSequencer();
62
+ // The ids the current `file` / `error` belong to. A fetch for different ids
63
+ // clears both first, so `{ file: A, error: B }` can never be returned.
64
+ const key = `${modelVersionId}|${fileId ?? ''}`;
65
+ const keyRef = useRef(null);
66
+ const isPositiveInt = (n) => Number.isInteger(n) && n > 0;
67
+ const valid = isPositiveInt(modelVersionId) && (fileId === undefined || isPositiveInt(fileId));
68
+ const refetch = useCallback(() => {
69
+ // `begin()` runs BEFORE the validity gate, as in `useWildcardPack`: moving to
70
+ // an invalid id must also supersede an in-flight request for the old one.
71
+ const token = seq.begin();
72
+ if (keyRef.current !== key) {
73
+ keyRef.current = key;
74
+ setFile(null);
75
+ setError(null);
76
+ }
77
+ if (!valid) {
78
+ setLoading(false);
79
+ return;
80
+ }
81
+ setLoading(true);
82
+ setError(null);
83
+ sendTypedRequest(getTransport(), {
84
+ type: 'GET_RESOURCE_FILE',
85
+ payload: fileId === undefined ? { modelVersionId } : { modelVersionId, fileId },
86
+ }, 'RESOURCE_FILE_RESULT', { timeoutMs: RESOURCE_FILE_REQUEST_TIMEOUT_MS })
87
+ .then((result) => {
88
+ if (!seq.isCurrent(token))
89
+ return;
90
+ if (result.error || !result.file) {
91
+ // `??`, as in `useWildcardPack`: `isValidResourceFileResult` admits only
92
+ // the closed code set, so `error: ''` never reaches here. The fallback
93
+ // covers only a reply with neither field, which the validator drops too.
94
+ setError(new ResourceFileError(result.error ?? 'parse-failed'));
95
+ setLoading(false);
96
+ return;
97
+ }
98
+ setFile(result.file);
99
+ setLoading(false);
100
+ })
101
+ .catch((err) => {
102
+ if (!seq.isCurrent(token))
103
+ return;
104
+ setError(err instanceof Error ? err : new Error(String(err)));
105
+ setLoading(false);
106
+ });
107
+ }, [valid, key, modelVersionId, fileId, seq]);
108
+ useEffect(() => {
109
+ refetch();
110
+ }, [refetch]);
111
+ return { file, loading, error, refetch };
112
+ }
113
+ //# sourceMappingURL=useResourceFile.js.map
package/dist/index.d.ts CHANGED
@@ -57,6 +57,8 @@ export { useDailyCompensation } from './hooks/useDailyCompensation.js';
57
57
  export type { UseDailyCompensation } from './hooks/useDailyCompensation.js';
58
58
  export { useWildcardPack, WildcardPackError } from './hooks/useWildcardPack.js';
59
59
  export type { UseWildcardPack } from './hooks/useWildcardPack.js';
60
+ export { useResourceFile, ResourceFileError } from './hooks/useResourceFile.js';
61
+ export type { UseResourceFile } from './hooks/useResourceFile.js';
60
62
  export { useAppWorkflows } from './hooks/useAppWorkflows.js';
61
63
  export type { UseAppWorkflows } from './hooks/useAppWorkflows.js';
62
64
  export { usePublishGenerationOutputs } from './hooks/usePublishGenerationOutputs.js';
@@ -77,7 +79,7 @@ export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTraini
77
79
  export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, BlockTrainingDatasetItem, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
78
80
  export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
79
81
  export type { UseRunConfirmedStep, QuotedStepBody, RunConfirmedStepErrorCode, BlockRunConfirmedStepHostError, BlockStepQuote, WorkflowBodyPassThroughStep, } from './hooks/useRunConfirmedStep.js';
80
- export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockAppReaction, BlockStoredReaction, BlockReactionEntityType, BlockEntityReactionErrorCode, BlockReactionCounts, BlockEntityReactionState, BlockEntityReactionResult, BlockEntityReactionsResult, } from '@civitai/app-sdk/blocks';
82
+ export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockAppReaction, BlockStoredReaction, BlockReactionEntityType, BlockEntityReactionErrorCode, BlockReactionCounts, BlockEntityReactionState, BlockEntityReactionResult, BlockEntityReactionsResult, } from '@civitai/app-sdk/blocks';
81
83
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
82
84
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
83
85
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export { useBuzzTransactions } from './hooks/useBuzzTransactions.js';
46
46
  export { useBuzzAccounts } from './hooks/useBuzzAccounts.js';
47
47
  export { useDailyCompensation } from './hooks/useDailyCompensation.js';
48
48
  export { useWildcardPack, WildcardPackError } from './hooks/useWildcardPack.js';
49
+ export { useResourceFile, ResourceFileError } from './hooks/useResourceFile.js';
49
50
  export { useAppWorkflows } from './hooks/useAppWorkflows.js';
50
51
  export { usePublishGenerationOutputs } from './hooks/usePublishGenerationOutputs.js';
51
52
  export { useGatedImages } from './hooks/useGatedImages.js';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 52, of which 14 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -127,6 +127,9 @@
127
127
  * `publishError` options. Replies with a refusal.
128
128
  * • GET_WILDCARD_PACK — needs the session-authed resolve plus the in-tab
129
129
  * zip/yaml parse that lives in civitai, not this SDK. Replies `parse-failed`.
130
+ * • GET_RESOURCE_FILE — the same session-authed resolve plus in-tab
131
+ * fetch/unzip/parse, and only full-page app hosts answer it. Replies
132
+ * `parse-failed`.
130
133
  * • OPEN_IMAGE_UPLOAD — needs the host's native modal + session-authed byte
131
134
  * pipeline. Replies DISMISSED (the hook resolves `null`).
132
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 52, of which 14 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -127,6 +127,9 @@
127
127
  * `publishError` options. Replies with a refusal.
128
128
  * • GET_WILDCARD_PACK — needs the session-authed resolve plus the in-tab
129
129
  * zip/yaml parse that lives in civitai, not this SDK. Replies `parse-failed`.
130
+ * • GET_RESOURCE_FILE — the same session-authed resolve plus in-tab
131
+ * fetch/unzip/parse, and only full-page app hosts answer it. Replies
132
+ * `parse-failed`.
130
133
  * • OPEN_IMAGE_UPLOAD — needs the host's native modal + session-authed byte
131
134
  * pipeline. Replies DISMISSED (the hook resolves `null`).
132
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
@@ -1207,6 +1210,26 @@ export function createLiveHost(options) {
1207
1210
  });
1208
1211
  return;
1209
1212
  }
1213
+ case 'GET_RESOURCE_FILE': {
1214
+ // UNSUPPORTED in live v1, for the same reason as GET_WILDCARD_PACK
1215
+ // directly above: the real host resolves the file through a
1216
+ // SESSION-authed procedure and fetches + unzips + parses it in the
1217
+ // viewer's page tab. A liveHost holds a BLOCK token, not a session,
1218
+ // so it cannot resolve the file. Reply with an honest `parse-failed`
1219
+ // (never a fabricated file) so the hook surfaces a typed error rather
1220
+ // than hanging. The closed error set has no "unsupported" code, and
1221
+ // `parse-failed` is the one the wildcard refusal already uses.
1222
+ if (!isRoutableRequestId(requestId))
1223
+ return;
1224
+ logOnce('resource-file', 'GET_RESOURCE_FILE is not supported in dev:live (it needs the session-authed ' +
1225
+ 'resolve + in-tab fetch and unzip, on a full-page app host). Replying ' +
1226
+ '`parse-failed`. Use dev:mock to exercise the resource-file path.');
1227
+ dispatchToBlock({
1228
+ type: 'RESOURCE_FILE_RESULT',
1229
+ payload: { requestId, error: 'parse-failed' },
1230
+ });
1231
+ return;
1232
+ }
1210
1233
  case 'QUERY_APP_WORKFLOWS': {
1211
1234
  // App generator SUBQUEUE read via the token-bound
1212
1235
  // `blocks.queryAppWorkflows` MUTATION (POST). params (cursor/limit) are
@@ -37,7 +37,7 @@
37
37
  * called). Only the postMessage bridge round-trips are exercised. Never import
38
38
  * this from production code.
39
39
  */
40
- import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockRunTrainingHostError, type BlockTrainingDataImage, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
40
+ import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type BlockResourceFile, type BlockResourceFileErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockRunTrainingHostError, type BlockTrainingDataImage, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  import { type MockMediaUploads } from './mediaUploadBytes.js';
42
42
  import { type MockWorkflowQuoteFailure } from './mockWorkflowGraph.js';
43
43
  import { type MockReactionSeed } from './mockHostReactions.js';
@@ -931,6 +931,21 @@ export interface MockHostOptions {
931
931
  * returned. Live-tunable via {@link MockHost.setScenario}.
932
932
  */
933
933
  wildcardPackError?: BlockWildcardPackErrorCode;
934
+ /**
935
+ * The parsed file returned from `GET_RESOURCE_FILE` (what `useResourceFile`
936
+ * reads). Absent → {@link DEFAULT_RESOURCE_FILE}, one UI-format graph. Ignored
937
+ * when {@link resourceFileError} is set. The mock returns it whatever
938
+ * `modelVersionId` / `fileId` the block asks for.
939
+ */
940
+ resourceFile?: BlockResourceFile;
941
+ /**
942
+ * Force `GET_RESOURCE_FILE` to reply with this code from the CLOSED set
943
+ * (`not-found` | `forbidden` | `unauthorized` | `too-large` | `parse-failed` |
944
+ * `busy` | `rate-limited`) instead of a file — exercises `useResourceFile`'s
945
+ * error UI. Absent → a file is returned. Live-tunable via
946
+ * {@link MockHost.setScenario}, where passing the key as `undefined` clears it.
947
+ */
948
+ resourceFileError?: BlockResourceFileErrorCode;
934
949
  /**
935
950
  * Force `SET_COLLECTION_FOLLOW` to reply with an `error` instead of writing —
936
951
  * exercises `useCollectionFollow`'s refusal handling. Pass a
@@ -1424,7 +1439,7 @@ export interface MockHostOptions {
1424
1439
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1425
1440
  * the viewer toggling dark mode with the block already mounted.)
1426
1441
  */
1427
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'cannedTrainingDataUpload' | 'trainingDataNotUploadedHere' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError' | 'cannedMediaUploads' | 'mediaUploadError'>;
1442
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'resourceFile' | 'resourceFileError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'cannedTrainingDataUpload' | 'trainingDataNotUploadedHere' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError' | 'cannedMediaUploads' | 'mediaUploadError'>;
1428
1443
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1429
1444
  export interface MockBuzzHandle {
1430
1445
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -1440,6 +1455,7 @@ export interface MockBuzzHandle {
1440
1455
  *
1441
1456
  * After install, a harness UI can drive scenarios live:
1442
1457
  * - `setScenario(patch)` — merge new generation/buzz/storage/failMode controls.
1458
+ * `setScenario({ resourceFileError: undefined })` clears a forced resource-file error.
1443
1459
  * - `setTheme('light' | 'dark')` — flip the site theme and push `THEME_CHANGE`.
1444
1460
  * - `buzz.setBalance(n)` / `buzz.getBalance()` — flip the simulated wallet.
1445
1461
  *
@@ -752,6 +752,38 @@ const DEFAULT_WILDCARD_PACK = {
752
752
  truncatedLists: [],
753
753
  maturity: { browsingLevel: SFW_LEVELS, sfwOnly: true },
754
754
  };
755
+ /**
756
+ * Default file reported on `GET_RESOURCE_FILE`: one plain `.json` workflow in the
757
+ * ComfyUI UI format (a `nodes` array), so a block renders a graph out of the box.
758
+ */
759
+ const DEFAULT_RESOURCE_FILE = {
760
+ meta: {
761
+ modelId: 618700,
762
+ modelVersionId: 691700,
763
+ fileId: 591700,
764
+ fileName: 'sample-workflow.json',
765
+ modelName: 'Sample Workflow',
766
+ versionName: 'v1.0',
767
+ creatorUsername: 'creator',
768
+ },
769
+ entries: [
770
+ {
771
+ path: 'sample-workflow.json',
772
+ format: 'ui',
773
+ graph: {
774
+ last_node_id: 2,
775
+ last_link_id: 1,
776
+ nodes: [
777
+ { id: 1, type: 'CheckpointLoaderSimple', inputs: [], outputs: [{ name: 'MODEL', links: [1] }] },
778
+ { id: 2, type: 'KSampler', inputs: [{ name: 'model', link: 1 }], outputs: [] },
779
+ ],
780
+ links: [[1, 1, 0, 2, 0, 'MODEL']],
781
+ version: 0.4,
782
+ },
783
+ },
784
+ ],
785
+ truncated: false,
786
+ };
755
787
  /**
756
788
  * Default app generator SUBQUEUE page reported on `QUERY_APP_WORKFLOWS`. A small
757
789
  * mixed-status list (a done gen with two images, one still processing) so a block
@@ -1136,6 +1168,9 @@ export function createMockHost(options = {}) {
1136
1168
  // Wildcard-pack bridge data + forced discriminated-error knob.
1137
1169
  let wildcardPack = options.wildcardPack ?? DEFAULT_WILDCARD_PACK;
1138
1170
  let wildcardPackError = options.wildcardPackError;
1171
+ // Resource-file bridge data + forced closed-set error knob.
1172
+ let resourceFile = options.resourceFile ?? DEFAULT_RESOURCE_FILE;
1173
+ let resourceFileError = options.resourceFileError;
1139
1174
  // Collection-follow bridge.
1140
1175
  //
1141
1176
  // 🔴 THERE IS DELIBERATELY NO FOLLOW-STATE MAP HERE, and an earlier version's
@@ -2616,6 +2651,25 @@ export function createMockHost(options = {}) {
2616
2651
  });
2617
2652
  return;
2618
2653
  }
2654
+ case 'GET_RESOURCE_FILE': {
2655
+ // Token-INDEPENDENT read, like GET_WILDCARD_PACK. A forced error
2656
+ // replies with the closed-set code (never free text), as the real
2657
+ // host does. The canned file is returned for any version / file id.
2658
+ if (!isRoutableRequestId(requestId))
2659
+ return;
2660
+ if (resourceFileError !== undefined) {
2661
+ dispatchToBlock({
2662
+ type: 'RESOURCE_FILE_RESULT',
2663
+ payload: { requestId, error: resourceFileError },
2664
+ });
2665
+ return;
2666
+ }
2667
+ dispatchToBlock({
2668
+ type: 'RESOURCE_FILE_RESULT',
2669
+ payload: { requestId, file: resourceFile },
2670
+ });
2671
+ return;
2672
+ }
2619
2673
  case 'QUERY_APP_WORKFLOWS': {
2620
2674
  // App generator SUBQUEUE read. Drop a request with no requestId
2621
2675
  // (unroutable). A forced error replies with the FREE-TEXT error
@@ -3921,6 +3975,11 @@ export function createMockHost(options = {}) {
3921
3975
  wildcardPack = patch.wildcardPack;
3922
3976
  if (patch.wildcardPackError !== undefined)
3923
3977
  wildcardPackError = patch.wildcardPackError;
3978
+ if (patch.resourceFile !== undefined)
3979
+ resourceFile = patch.resourceFile;
3980
+ // Key PRESENCE, not value: `{ resourceFileError: undefined }` clears it.
3981
+ if ('resourceFileError' in patch)
3982
+ resourceFileError = patch.resourceFileError;
3924
3983
  if (patch.collectionFollowError !== undefined)
3925
3984
  collectionFollowError = patch.collectionFollowError;
3926
3985
  if (patch.reactionStates !== undefined || patch.reactionError !== undefined) {
@@ -59,6 +59,19 @@ export declare const TRAINING_DATA_UPLOAD_TIMEOUT_MS: number;
59
59
  * caller shares the number instead of re-typing it.
60
60
  */
61
61
  export declare const WORKFLOW_REQUEST_TIMEOUT_MS = 120000;
62
+ /**
63
+ * The bound for `GET_RESOURCE_FILE` (`hooks/useResourceFile.ts`), a `'protocol'`
64
+ * request whose HOST work is slow: a session-authed resolve, a fetch of the
65
+ * signed URL (up to 32 MB), then an unzip and parse of up to 16 MB in the
66
+ * viewer's tab. The host bounds all three with ONE 110 s deadline
67
+ * (civitai/civitai#6818 `RESOURCE_FILE_DEADLINE_MS`), after which it replies
68
+ * `parse-failed`. At {@link DEFAULT_REQUEST_TIMEOUT_MS} a large file on a slow
69
+ * connection would time out on the block while the host was still working, and
70
+ * the host's valid reply would be dropped as unroutable. 120 s leaves the host's
71
+ * 110 s deadline room to answer first. Same number as
72
+ * {@link WORKFLOW_REQUEST_TIMEOUT_MS}.
73
+ */
74
+ export declare const RESOURCE_FILE_REQUEST_TIMEOUT_MS = 120000;
62
75
  /** Which bound a block→parent message is answered under. */
63
76
  export type RequestTimeoutClass =
64
77
  /** Reply waits on a PERSON — must pass {@link HUMAN_INTERACTION_TIMEOUT_MS}. */
@@ -58,6 +58,19 @@ export const TRAINING_DATA_UPLOAD_TIMEOUT_MS = 60 * 60_000;
58
58
  * caller shares the number instead of re-typing it.
59
59
  */
60
60
  export const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
61
+ /**
62
+ * The bound for `GET_RESOURCE_FILE` (`hooks/useResourceFile.ts`), a `'protocol'`
63
+ * request whose HOST work is slow: a session-authed resolve, a fetch of the
64
+ * signed URL (up to 32 MB), then an unzip and parse of up to 16 MB in the
65
+ * viewer's tab. The host bounds all three with ONE 110 s deadline
66
+ * (civitai/civitai#6818 `RESOURCE_FILE_DEADLINE_MS`), after which it replies
67
+ * `parse-failed`. At {@link DEFAULT_REQUEST_TIMEOUT_MS} a large file on a slow
68
+ * connection would time out on the block while the host was still working, and
69
+ * the host's valid reply would be dropped as unroutable. 120 s leaves the host's
70
+ * 110 s deadline room to answer first. Same number as
71
+ * {@link WORKFLOW_REQUEST_TIMEOUT_MS}.
72
+ */
73
+ export const RESOURCE_FILE_REQUEST_TIMEOUT_MS = 120_000;
61
74
  /**
62
75
  * 🔴 THE TOTALITY GATE — every block→parent message type, bucketed.
63
76
  *
@@ -158,6 +171,10 @@ const REQUEST_TIMEOUT_CLASS = {
158
171
  GET_IMAGES_BY_IDS: 'protocol',
159
172
  GET_VIEWER: 'protocol',
160
173
  GET_WILDCARD_PACK: 'protocol',
174
+ // No person in the loop, so `'protocol'` — but sent under its own longer
175
+ // `RESOURCE_FILE_REQUEST_TIMEOUT_MS`: the host's resolve + fetch + unzip run
176
+ // under a 110 s deadline, which the 30 s default would cut short.
177
+ GET_RESOURCE_FILE: 'protocol',
161
178
  QUERY_APP_WORKFLOWS: 'protocol',
162
179
  REQUEST_TOKEN: 'protocol',
163
180
  // The host fetches the blob and triggers the browser's own download; it replies
@@ -347,6 +347,17 @@ export declare function isValidDailyCompensationResult(p: unknown): boolean;
347
347
  * reply, mirroring the host's enum contract.
348
348
  */
349
349
  export declare function isValidWildcardPackResult(p: unknown): boolean;
350
+ /**
351
+ * Reply to a block-initiated `GET_RESOURCE_FILE`. Same contract shape as
352
+ * {@link isValidWildcardPackResult}: EITHER a `file` OR an `error` that is one of
353
+ * {@link RESOURCE_FILE_ERROR_CODES}; a free-text or unknown `error`, or a reply
354
+ * with neither, is malformed and dropped. The `file` is checked field by field —
355
+ * `meta` ids positive integers, names strings, `creatorUsername` a string or
356
+ * `null`, `truncated` a boolean, and EVERY entry an object with a string `path`,
357
+ * a known `format` and a `graph` key. `graph` itself is the host's parsed JSON
358
+ * and may be any JSON value.
359
+ */
360
+ export declare function isValidResourceFileResult(p: unknown): boolean;
350
361
  /**
351
362
  * Reply to a block-initiated `QUERY_APP_WORKFLOWS`. A well-formed reply carries
352
363
  * EITHER a `result` ({ workflows: AppWorkflow[], cursor: string | null }) OR a
@@ -1040,6 +1040,80 @@ export function isValidWildcardPackResult(p) {
1040
1040
  return false;
1041
1041
  return true;
1042
1042
  }
1043
+ /**
1044
+ * The CLOSED `RESOURCE_FILE_RESULT` error set, mirroring
1045
+ * `BlockResourceFileErrorCode` (civitai/civitai-app-starters#664).
1046
+ */
1047
+ const RESOURCE_FILE_ERROR_CODES = new Set([
1048
+ 'not-found',
1049
+ 'forbidden',
1050
+ 'unauthorized',
1051
+ 'too-large',
1052
+ 'parse-failed',
1053
+ 'busy',
1054
+ 'rate-limited',
1055
+ ]);
1056
+ const RESOURCE_FILE_ENTRY_FORMATS = new Set(['ui', 'api', 'unknown']);
1057
+ /**
1058
+ * Reply to a block-initiated `GET_RESOURCE_FILE`. Same contract shape as
1059
+ * {@link isValidWildcardPackResult}: EITHER a `file` OR an `error` that is one of
1060
+ * {@link RESOURCE_FILE_ERROR_CODES}; a free-text or unknown `error`, or a reply
1061
+ * with neither, is malformed and dropped. The `file` is checked field by field —
1062
+ * `meta` ids positive integers, names strings, `creatorUsername` a string or
1063
+ * `null`, `truncated` a boolean, and EVERY entry an object with a string `path`,
1064
+ * a known `format` and a `graph` key. `graph` itself is the host's parsed JSON
1065
+ * and may be any JSON value.
1066
+ */
1067
+ export function isValidResourceFileResult(p) {
1068
+ if (!isObject(p))
1069
+ return false;
1070
+ if (!isWireRequestIdShape(p.requestId))
1071
+ return false;
1072
+ if (p.error !== undefined) {
1073
+ if (typeof p.error !== 'string' || !RESOURCE_FILE_ERROR_CODES.has(p.error))
1074
+ return false;
1075
+ }
1076
+ if (p.file !== undefined) {
1077
+ const file = p.file;
1078
+ if (!isObject(file))
1079
+ return false;
1080
+ const meta = file.meta;
1081
+ if (!isObject(meta))
1082
+ return false;
1083
+ if (!isPositiveInteger(meta.modelId))
1084
+ return false;
1085
+ if (!isPositiveInteger(meta.modelVersionId))
1086
+ return false;
1087
+ if (!isPositiveInteger(meta.fileId))
1088
+ return false;
1089
+ if (typeof meta.fileName !== 'string')
1090
+ return false;
1091
+ if (typeof meta.modelName !== 'string')
1092
+ return false;
1093
+ if (typeof meta.versionName !== 'string')
1094
+ return false;
1095
+ if (meta.creatorUsername !== null && typeof meta.creatorUsername !== 'string')
1096
+ return false;
1097
+ if (typeof file.truncated !== 'boolean')
1098
+ return false;
1099
+ if (!Array.isArray(file.entries))
1100
+ return false;
1101
+ for (const entry of file.entries) {
1102
+ if (!isObject(entry))
1103
+ return false;
1104
+ if (typeof entry.path !== 'string')
1105
+ return false;
1106
+ if (typeof entry.format !== 'string' || !RESOURCE_FILE_ENTRY_FORMATS.has(entry.format)) {
1107
+ return false;
1108
+ }
1109
+ if (!('graph' in entry))
1110
+ return false;
1111
+ }
1112
+ }
1113
+ if (p.file === undefined && p.error === undefined)
1114
+ return false;
1115
+ return true;
1116
+ }
1043
1117
  // ============================================================
1044
1118
  // App generator SUBQUEUE reply validators
1045
1119
  // ============================================================
@@ -2302,6 +2376,8 @@ export function payloadValidatorFor(type) {
2302
2376
  return isValidDailyCompensationResult;
2303
2377
  case 'WILDCARD_PACK_RESULT':
2304
2378
  return isValidWildcardPackResult;
2379
+ case 'RESOURCE_FILE_RESULT':
2380
+ return isValidResourceFileResult;
2305
2381
  case 'APP_WORKFLOWS_RESULT':
2306
2382
  return isValidAppWorkflowsResult;
2307
2383
  case 'CANCEL_APP_WORKFLOW_RESULT':
@@ -1,3 +1,3 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.77.0";
2
+ export declare const VERSION = "0.78.0";
3
3
  //# sourceMappingURL=version.generated.d.ts.map
@@ -1,4 +1,4 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.77.0";
3
+ export const VERSION = "0.78.0";
4
4
  //# sourceMappingURL=version.generated.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.77.0"
61
+ "@civitai/app-sdk": "^0.79.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"