@dxos/plugin-file 0.9.1-staging.ee54ba693a → 0.11.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.
Files changed (174) hide show
  1. package/PLUGIN.mdl +86 -77
  2. package/dist/lib/FilePlugin.mjs +37 -0
  3. package/dist/lib/FilePlugin.mjs.map +1 -0
  4. package/dist/lib/FilePlugin.node.mjs +12 -0
  5. package/dist/lib/FilePlugin.node.mjs.map +1 -0
  6. package/dist/lib/capabilities/node.mjs +8 -0
  7. package/dist/lib/capabilities/node.mjs.map +1 -0
  8. package/dist/lib/capabilities.mjs +15 -0
  9. package/dist/lib/capabilities.mjs.map +1 -0
  10. package/dist/lib/chunk-FileArticle.mjs +62 -0
  11. package/dist/lib/chunk-FileArticle.mjs.map +1 -0
  12. package/dist/lib/chunk-create-object.mjs +28 -0
  13. package/dist/lib/chunk-create-object.mjs.map +1 -0
  14. package/dist/lib/chunk-create.mjs +78 -0
  15. package/dist/lib/chunk-create.mjs.map +1 -0
  16. package/dist/lib/chunk-edge-backend.mjs +18 -0
  17. package/dist/lib/chunk-edge-backend.mjs.map +1 -0
  18. package/dist/lib/chunk-file-uploader.mjs +37 -0
  19. package/dist/lib/chunk-file-uploader.mjs.map +1 -0
  20. package/dist/lib/chunk-inline-backend.mjs +19 -0
  21. package/dist/lib/chunk-inline-backend.mjs.map +1 -0
  22. package/dist/lib/chunk-markdown-extension.mjs +182 -0
  23. package/dist/lib/chunk-markdown-extension.mjs.map +1 -0
  24. package/dist/lib/chunk-meta.mjs +44 -0
  25. package/dist/lib/chunk-meta.mjs.map +1 -0
  26. package/dist/lib/chunk-operation-handler.mjs +11 -0
  27. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  28. package/dist/lib/chunk-react-surface.mjs +55 -0
  29. package/dist/lib/chunk-react-surface.mjs.map +1 -0
  30. package/dist/lib/chunk-read.mjs +32 -0
  31. package/dist/lib/chunk-read.mjs.map +1 -0
  32. package/dist/lib/chunk-settings.mjs +23 -0
  33. package/dist/lib/chunk-settings.mjs.map +1 -0
  34. package/dist/lib/chunk-skill-definition.mjs +10 -0
  35. package/dist/lib/chunk-skill-definition.mjs.map +1 -0
  36. package/dist/lib/chunk-skills.mjs +28 -0
  37. package/dist/lib/chunk-skills.mjs.map +1 -0
  38. package/dist/lib/chunk-types.mjs +98 -0
  39. package/dist/lib/chunk-types.mjs.map +1 -0
  40. package/dist/lib/components.mjs +57 -0
  41. package/dist/lib/components.mjs.map +1 -0
  42. package/dist/lib/containers.mjs +56 -0
  43. package/dist/lib/containers.mjs.map +1 -0
  44. package/dist/lib/index.mjs +4 -0
  45. package/dist/lib/meta.mjs +2 -0
  46. package/dist/lib/operations.mjs +7 -0
  47. package/dist/lib/operations.mjs.map +1 -0
  48. package/dist/lib/plugin.mjs +9 -0
  49. package/dist/lib/plugin.mjs.map +1 -0
  50. package/dist/lib/skills.mjs +2 -0
  51. package/dist/lib/translations.mjs +30 -0
  52. package/dist/lib/translations.mjs.map +1 -0
  53. package/dist/lib/types.mjs +2 -0
  54. package/dist/types/src/FilePlugin.d.ts.map +1 -1
  55. package/dist/types/src/FilePlugin.node.d.ts.map +1 -1
  56. package/dist/types/src/capabilities/FileUploadField.d.ts +12 -0
  57. package/dist/types/src/capabilities/FileUploadField.d.ts.map +1 -0
  58. package/dist/types/src/capabilities/create-object.d.ts +1 -1
  59. package/dist/types/src/capabilities/edge-backend.d.ts +5 -0
  60. package/dist/types/src/capabilities/edge-backend.d.ts.map +1 -0
  61. package/dist/types/src/capabilities/file-uploader.d.ts +1 -1
  62. package/dist/types/src/capabilities/file-uploader.d.ts.map +1 -1
  63. package/dist/types/src/capabilities/index.d.ts +1 -0
  64. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  65. package/dist/types/src/capabilities/inline-backend.d.ts +2 -2
  66. package/dist/types/src/capabilities/inline-backend.d.ts.map +1 -1
  67. package/dist/types/src/capabilities/markdown-extension.d.ts +1 -1
  68. package/dist/types/src/capabilities/node.d.ts +5 -0
  69. package/dist/types/src/capabilities/node.d.ts.map +1 -0
  70. package/dist/types/src/capabilities/operation-handler.d.ts +1 -1
  71. package/dist/types/src/capabilities/react-surface.d.ts +1 -1
  72. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  73. package/dist/types/src/capabilities/settings.d.ts +1 -1
  74. package/dist/types/src/components/FileInput/FileInput.d.ts.map +1 -1
  75. package/dist/types/src/components/FilePreview/FilePreview.d.ts +1 -1
  76. package/dist/types/src/components/FilePreview/FilePreview.d.ts.map +1 -1
  77. package/dist/types/src/containers/FileArticle/FileArticle.d.ts +4 -1
  78. package/dist/types/src/containers/FileArticle/FileArticle.d.ts.map +1 -1
  79. package/dist/types/src/containers/FileSettings/FileSettings.d.ts +5 -3
  80. package/dist/types/src/containers/FileSettings/FileSettings.d.ts.map +1 -1
  81. package/dist/types/src/extensions/image.d.ts +0 -2
  82. package/dist/types/src/extensions/image.d.ts.map +1 -1
  83. package/dist/types/src/operations/create.d.ts +14 -5
  84. package/dist/types/src/operations/create.d.ts.map +1 -1
  85. package/dist/types/src/operations/read.d.ts.map +1 -1
  86. package/dist/types/src/types/FileCapabilities.d.ts +10 -28
  87. package/dist/types/src/types/FileCapabilities.d.ts.map +1 -1
  88. package/dist/types/src/types/Settings.d.ts +1 -1
  89. package/dist/types/src/types/Settings.d.ts.map +1 -1
  90. package/dist/types/src/types/limits.d.ts +0 -1
  91. package/dist/types/src/types/limits.d.ts.map +1 -1
  92. package/dist/types/tsconfig.tsbuildinfo +1 -1
  93. package/dx.config.ts +10 -6
  94. package/package.json +45 -41
  95. package/src/FilePlugin.node.ts +2 -1
  96. package/src/FilePlugin.tsx +8 -1
  97. package/src/capabilities/FileUploadField.tsx +37 -0
  98. package/src/capabilities/edge-backend.ts +27 -0
  99. package/src/capabilities/file-uploader.ts +6 -4
  100. package/src/capabilities/index.ts +1 -0
  101. package/src/capabilities/inline-backend.ts +4 -21
  102. package/src/capabilities/markdown-extension.ts +3 -8
  103. package/src/capabilities/node.ts +16 -0
  104. package/src/capabilities/react-surface.ts +46 -0
  105. package/src/components/FileInput/FileInput.tsx +3 -2
  106. package/src/containers/FileArticle/FileArticle.tsx +49 -42
  107. package/src/containers/FileSettings/FileSettings.tsx +19 -14
  108. package/src/extensions/image.tsx +49 -28
  109. package/src/operations/create.test.ts +122 -32
  110. package/src/operations/create.ts +44 -24
  111. package/src/operations/read.ts +24 -6
  112. package/src/types/FileCapabilities.ts +10 -31
  113. package/src/types/Settings.ts +1 -1
  114. package/src/types/limits.ts +0 -2
  115. package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs +0 -80
  116. package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs.map +0 -7
  117. package/dist/lib/neutral/FilePlugin.mjs +0 -58
  118. package/dist/lib/neutral/FilePlugin.mjs.map +0 -7
  119. package/dist/lib/neutral/FilePlugin.node.mjs +0 -23
  120. package/dist/lib/neutral/FilePlugin.node.mjs.map +0 -7
  121. package/dist/lib/neutral/capabilities/index.mjs +0 -23
  122. package/dist/lib/neutral/capabilities/index.mjs.map +0 -7
  123. package/dist/lib/neutral/chunk-DKGTSUUH.mjs +0 -8
  124. package/dist/lib/neutral/chunk-DKGTSUUH.mjs.map +0 -7
  125. package/dist/lib/neutral/chunk-GMYSEHV4.mjs +0 -40
  126. package/dist/lib/neutral/chunk-GMYSEHV4.mjs.map +0 -7
  127. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  128. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  129. package/dist/lib/neutral/chunk-LBZ4MCLI.mjs +0 -45
  130. package/dist/lib/neutral/chunk-LBZ4MCLI.mjs.map +0 -7
  131. package/dist/lib/neutral/chunk-YODPX62X.mjs +0 -136
  132. package/dist/lib/neutral/chunk-YODPX62X.mjs.map +0 -7
  133. package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs +0 -80
  134. package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs.map +0 -7
  135. package/dist/lib/neutral/components/index.mjs +0 -74
  136. package/dist/lib/neutral/components/index.mjs.map +0 -7
  137. package/dist/lib/neutral/containers/index.mjs +0 -59
  138. package/dist/lib/neutral/containers/index.mjs.map +0 -7
  139. package/dist/lib/neutral/create-IUUG7RI6.mjs +0 -17
  140. package/dist/lib/neutral/create-IUUG7RI6.mjs.map +0 -7
  141. package/dist/lib/neutral/create-object-V667G4ZL.mjs +0 -30
  142. package/dist/lib/neutral/create-object-V667G4ZL.mjs.map +0 -7
  143. package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs +0 -41
  144. package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs.map +0 -7
  145. package/dist/lib/neutral/index.mjs +0 -32
  146. package/dist/lib/neutral/index.mjs.map +0 -7
  147. package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs +0 -37
  148. package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs.map +0 -7
  149. package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs +0 -230
  150. package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs.map +0 -7
  151. package/dist/lib/neutral/meta.json +0 -1
  152. package/dist/lib/neutral/meta.mjs +0 -8
  153. package/dist/lib/neutral/meta.mjs.map +0 -7
  154. package/dist/lib/neutral/operation-handler-CPDANWGH.mjs +0 -13
  155. package/dist/lib/neutral/operation-handler-CPDANWGH.mjs.map +0 -7
  156. package/dist/lib/neutral/operations/index.mjs +0 -8
  157. package/dist/lib/neutral/operations/index.mjs.map +0 -7
  158. package/dist/lib/neutral/plugin.mjs +0 -16
  159. package/dist/lib/neutral/plugin.mjs.map +0 -7
  160. package/dist/lib/neutral/react-surface-5BXKSHD2.mjs +0 -60
  161. package/dist/lib/neutral/react-surface-5BXKSHD2.mjs.map +0 -7
  162. package/dist/lib/neutral/read-HG6CSWKR.mjs +0 -28
  163. package/dist/lib/neutral/read-HG6CSWKR.mjs.map +0 -7
  164. package/dist/lib/neutral/settings-RR3RESL2.mjs +0 -28
  165. package/dist/lib/neutral/settings-RR3RESL2.mjs.map +0 -7
  166. package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs +0 -15
  167. package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs.map +0 -7
  168. package/dist/lib/neutral/skills/index.mjs +0 -11
  169. package/dist/lib/neutral/skills/index.mjs.map +0 -7
  170. package/dist/lib/neutral/translations.mjs +0 -36
  171. package/dist/lib/neutral/translations.mjs.map +0 -7
  172. package/dist/lib/neutral/types/index.mjs +0 -22
  173. package/dist/lib/neutral/types/index.mjs.map +0 -7
  174. package/src/capabilities/react-surface.tsx +0 -61
@@ -4,28 +4,32 @@
4
4
 
5
5
  import React, { useCallback } from 'react';
6
6
 
7
- import { useCapabilities } from '@dxos/app-framework/ui';
7
+ import { useCapabilities, useSettingsState } from '@dxos/app-framework/ui';
8
8
  import { type AppSurface } from '@dxos/app-toolkit/ui';
9
+ import { useClient } from '@dxos/react-client';
9
10
  import { Select, useTranslation } from '@dxos/react-ui';
10
11
  import { Form } from '@dxos/react-ui-form';
11
12
 
12
13
  import { meta } from '#meta';
13
14
  import { FileCapabilities, Settings } from '#types';
14
15
 
15
- export type FileSettingsProps = AppSurface.SettingsProps<Settings.Settings>;
16
+ export type FileSettingsProps = AppSurface.SettingsData;
16
17
 
17
- export const FileSettings = ({ settings, onSettingsChange }: FileSettingsProps) => {
18
+ export const FileSettings = ({ subject }: FileSettingsProps) => {
18
19
  const { t } = useTranslation(meta.profile.key);
20
+ const { settings, updateSettings } = useSettingsState<Settings.Settings>(subject.atom);
21
+ const client = useClient();
19
22
  const backends = useCapabilities(FileCapabilities.Backend);
20
- const requestedId = settings.backend ?? Settings.DEFAULT_BACKEND_ID;
21
- const active =
22
- backends.find((b) => b.id === requestedId) ?? backends.find((b) => b.id === Settings.DEFAULT_BACKEND_ID);
23
- // Use the resolved backend id so the Select never shows a missing/stale value.
24
- const activeId = active?.id ?? Settings.DEFAULT_BACKEND_ID;
23
+ // No explicit choice defers to the Blob registry's own configured default (edge when
24
+ // configured, inline otherwise), so the Select reflects what an upload will actually use.
25
+ const requested = settings.backend ? backends.find((b) => b.storage === settings.backend) : undefined;
26
+ const active = requested ?? backends.find((b) => b.storage === client.graph.defaultBlobStorage) ?? backends[0];
27
+ // Use the resolved backend's storage name so the Select never shows a missing/stale value.
28
+ const activeStorage = active?.storage ?? Settings.DEFAULT_BACKEND_STORAGE;
25
29
 
26
30
  const handleChange = useCallback(
27
- (value: string) => onSettingsChange?.((current) => ({ ...current, backend: value })),
28
- [onSettingsChange],
31
+ (value: string) => updateSettings((current) => ({ ...current, backend: value })),
32
+ [updateSettings],
29
33
  );
30
34
 
31
35
  return (
@@ -33,8 +37,7 @@ export const FileSettings = ({ settings, onSettingsChange }: FileSettingsProps)
33
37
  schema={Settings.Settings}
34
38
  values={settings}
35
39
  variant='settings'
36
- readonly={!onSettingsChange}
37
- onValuesChanged={(values) => onSettingsChange?.((current) => ({ ...current, ...values }))}
40
+ onValuesChanged={(values) => updateSettings((current) => ({ ...current, ...values }))}
38
41
  >
39
42
  <Form.Viewport scroll>
40
43
  <Form.Content>
@@ -43,13 +46,13 @@ export const FileSettings = ({ settings, onSettingsChange }: FileSettingsProps)
43
46
  label={t('settings.backend.label')}
44
47
  description={active?.description ?? t('settings.backend.description')}
45
48
  >
46
- <Select.Root value={activeId} onValueChange={handleChange} disabled={!onSettingsChange}>
49
+ <Select.Root value={activeStorage} onValueChange={handleChange}>
47
50
  <Select.TriggerButton placeholder={t('settings.backend.placeholder')} />
48
51
  <Select.Portal>
49
52
  <Select.Content>
50
53
  <Select.Viewport>
51
54
  {backends.map((backend) => (
52
- <Select.Option key={backend.id} value={backend.id}>
55
+ <Select.Option key={backend.storage} value={backend.storage}>
53
56
  {backend.name}
54
57
  </Select.Option>
55
58
  ))}
@@ -65,3 +68,5 @@ export const FileSettings = ({ settings, onSettingsChange }: FileSettingsProps)
65
68
  </Form.Root>
66
69
  );
67
70
  };
71
+
72
+ FileSettings.displayName = 'FileSettings';
@@ -5,10 +5,13 @@
5
5
  import { syntaxTree } from '@codemirror/language';
6
6
  import { type EditorState, type Extension, type Range, StateField } from '@codemirror/state';
7
7
  import { Decoration, EditorView, WidgetType } from '@codemirror/view';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Option from 'effect/Option';
8
10
  import React from 'react';
9
11
  import { createRoot } from 'react-dom/client';
10
12
 
11
- import { Filter, Obj } from '@dxos/echo';
13
+ import { Blob, Database, Filter, Obj } from '@dxos/echo';
14
+ import { EffectEx } from '@dxos/effect';
12
15
  import { EID } from '@dxos/keys';
13
16
  import { type Space } from '@dxos/react-client/echo';
14
17
  import { Status, ThemeProvider } from '@dxos/react-ui';
@@ -16,13 +19,12 @@ import { defaultTx } from '@dxos/react-ui';
16
19
  import { focusField } from '@dxos/ui-editor';
17
20
  import { type MaybePromise } from '@dxos/util';
18
21
 
19
- import { File, FileCapabilities } from '#types';
22
+ import { File } from '#types';
20
23
 
21
24
  const WAIT_UNTIL_LOADER = 1500;
22
25
 
23
26
  export type ImageOptions = {
24
27
  space: Space;
25
- resolvers: readonly FileCapabilities.UrlResolver[];
26
28
  };
27
29
 
28
30
  /**
@@ -79,7 +81,7 @@ const buildDecorations = ({
79
81
  to,
80
82
  blobUrlCache,
81
83
  preload,
82
- options: { space, resolvers },
84
+ options: { space },
83
85
  }: {
84
86
  state: EditorState;
85
87
  from: number;
@@ -126,34 +128,53 @@ const buildDecorations = ({
126
128
  }
127
129
 
128
130
  const cacheKey = urlText;
129
- const blobUrlPromise = (async () => {
130
- const matched = await space.db.query(Filter.id(echoUri!)).first();
131
- if (!matched || !Obj.instanceOf(File.File, matched)) {
132
- return undefined;
133
- }
134
-
135
- let url: string | undefined;
136
- const { data } = matched;
137
- if (data._tag === 'inline') {
138
- url = URL.createObjectURL(new Blob([data.bytes as BlobPart], { type: matched.type }));
139
- } else if (/^(?:https?|data|blob):/i.test(data.url)) {
140
- url = data.url;
141
- } else {
142
- const resolver = resolvers.find((r) => r.test(data.url));
143
- url = await resolver?.resolve(data.url, matched, space);
144
- }
145
- if (!url) {
146
- return undefined;
147
- }
148
- blobUrlCache[cacheKey] = url;
149
- preload(url);
150
- return url;
151
- })();
131
+ const cached = blobUrlCache[cacheKey];
132
+ // Skip the DB query and Blob resolution entirely once a URL is cached — otherwise every
133
+ // decoration rebuild (e.g. on cursor movement) re-fetches and, on the object-URL fallback
134
+ // path, mints and leaks a new `blob:` URL even though the cached one is still valid.
135
+ const blobUrlPromise =
136
+ cached ??
137
+ (async () => {
138
+ const matched = await space.db.query(Filter.id(echoUri!)).first();
139
+ if (!matched || !Obj.instanceOf(File.File, matched)) {
140
+ return undefined;
141
+ }
142
+
143
+ const url = await EffectEx.runPromise(
144
+ Effect.gen(function* () {
145
+ const blob = yield* Database.load(matched.data);
146
+ const urlOption = yield* Blob.url(blob);
147
+ if (Option.isSome(urlOption)) {
148
+ return urlOption.value;
149
+ }
150
+ const bytes = yield* Blob.read(blob);
151
+ // `Uint8Array` is generic over `ArrayBufferLike` (incl. `SharedArrayBuffer`) while
152
+ // DOM's `BlobPart` only covers `ArrayBuffer`-backed views — a gap between the DOM lib
153
+ // types and the TS standard lib, not fixable by typing `bytes` differently.
154
+ return URL.createObjectURL(new globalThis.Blob([bytes as BlobPart], { type: blob.type }));
155
+ }).pipe(
156
+ Effect.provide(Database.layer(space.db)),
157
+ Effect.catchAll(() => Effect.succeed(undefined)),
158
+ ),
159
+ );
160
+ if (!url) {
161
+ return undefined;
162
+ }
163
+ // Only object URLs we minted via `createObjectURL` above need revoking — `Blob.url()`
164
+ // results (data:/https: URLs) aren't owned by this cache.
165
+ const previous = blobUrlCache[cacheKey];
166
+ if (previous?.startsWith('blob:')) {
167
+ URL.revokeObjectURL(previous);
168
+ }
169
+ blobUrlCache[cacheKey] = url;
170
+ preload(url);
171
+ return url;
172
+ })();
152
173
 
153
174
  decorations.push(
154
175
  Decoration.replace({
155
176
  block: true,
156
- widget: new DxnImageWidget(urlText, blobUrlCache[cacheKey] ?? blobUrlPromise),
177
+ widget: new DxnImageWidget(urlText, blobUrlPromise),
157
178
  }).range(hide ? node.from : node.to, node.to),
158
179
  );
159
180
  },
@@ -2,52 +2,142 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ import * as Cause from 'effect/Cause';
6
+ import * as Effect from 'effect/Effect';
5
7
  import { describe, test } from 'vitest';
6
8
 
7
- import { inlineBackend } from '../capabilities/inline-backend';
8
- import { MAX_FILE_SIZE } from '../types';
9
- import { FileTooLargeError, UnsupportedFileTypeError } from './create';
9
+ import { Operation } from '@dxos/compute';
10
+ import { Blob, Database } from '@dxos/echo';
11
+ import { EffectEx } from '@dxos/effect';
12
+ import { ClientCapabilities, ClientEvents } from '@dxos/plugin-client';
13
+ import { ClientPlugin, initializeIdentity } from '@dxos/plugin-client/testing';
14
+ import { createComposerTestApp } from '@dxos/plugin-testing/harness';
15
+
16
+ import { FilePlugin } from '#plugin';
17
+ import { FileCapabilities, FileOperation } from '#types';
10
18
 
11
- const makeFile = (name: string, type: string, bytes: Uint8Array): File => new File([bytes as BlobPart], name, { type });
19
+ import { FileTooLargeError, UnsupportedFileTypeError } from './create';
12
20
 
13
- // Database is unused by the inline backend, but the type signature requires it.
14
- const stubDb: any = {};
21
+ describe('FileOperation.Create', () => {
22
+ test('uploads a small PNG to the default (inline) backend', async ({ expect }) => {
23
+ const { harness, personalSpace } = await setup();
24
+ await using _harness = harness;
15
25
 
16
- describe('inlineBackend.upload', () => {
17
- test('produces an inline FileData for a small PNG', async ({ expect }) => {
18
26
  const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
19
- const result = await inlineBackend.upload(makeFile('icon.png', 'image/png', bytes), stubDb);
20
- expect(result.name).toBe('icon.png');
21
- expect(result.type).toBe('image/png');
22
- expect(result.size).toBe(bytes.byteLength);
23
- expect(result.data._tag).toBe('inline');
24
- expect(result.data._tag === 'inline' && result.data.bytes).toBeInstanceOf(Uint8Array);
25
- expect(result.data._tag === 'inline' && result.data.bytes.byteLength).toBe(bytes.byteLength);
26
- });
27
+ await harness.runPromise(
28
+ Effect.gen(function* () {
29
+ const { object } = yield* Operation.invoke(
30
+ FileOperation.Create,
31
+ { file: makeFile('icon.png', 'image/png', bytes), db: personalSpace.db },
32
+ { spaceId: personalSpace.id },
33
+ );
27
34
 
28
- test('produces an inline FileData for a PDF', async ({ expect }) => {
29
- const result = await inlineBackend.upload(makeFile('doc.pdf', 'application/pdf', new Uint8Array(1024)), stubDb);
30
- expect(result.type).toBe('application/pdf');
31
- expect(result.size).toBe(1024);
32
- expect(result.data._tag).toBe('inline');
35
+ expect(object.name).toBe('icon.png');
36
+
37
+ const blob = yield* Database.load(object.data);
38
+ expect(blob.type).toBe('image/png');
39
+ expect(blob.size).toBe(bytes.byteLength);
40
+ expect(blob.data._tag).toBe('inline');
41
+ }),
42
+ );
33
43
  });
34
44
 
35
- test('rejects files larger than 4MB', async ({ expect }) => {
36
- const oversized = new Uint8Array(MAX_FILE_SIZE + 1);
37
- await expect(inlineBackend.upload(makeFile('big.png', 'image/png', oversized), stubDb)).rejects.toThrow(
38
- FileTooLargeError,
45
+ test('uploads via the Blob registry default when no Settings.backend is configured', async ({ expect }) => {
46
+ const { harness, personalSpace } = await setup();
47
+ await using _harness = harness;
48
+
49
+ // Mimics `@dxos/client` registering 'edge' as the default once configured — a second
50
+ // storage backend registered with `{ default: true }`, with a matching FileCapabilities
51
+ // descriptor contributed (as plugin-file's own EdgeBackend module would).
52
+ const store = new Map<string, Uint8Array>();
53
+ const cleanup = personalSpace.db.graph.registerBlobBackend(
54
+ 'mem',
55
+ {
56
+ schemes: ['mem'],
57
+ put: async ({ data, contentHash }) => {
58
+ const uri = `mem:${contentHash}`;
59
+ store.set(uri, data);
60
+ return { uri };
61
+ },
62
+ get: async ({ uri }) => store.get(uri),
63
+ has: async ({ uri }) => store.has(uri),
64
+ },
65
+ { default: true },
39
66
  );
67
+ harness.capabilities.contribute({
68
+ module: 'test',
69
+ interface: FileCapabilities.Backend,
70
+ implementation: { name: 'Mem', storage: 'mem' },
71
+ });
72
+
73
+ try {
74
+ const bytes = new Uint8Array([1, 2, 3, 4]);
75
+ await harness.runPromise(
76
+ Effect.gen(function* () {
77
+ // No Settings.backend configured — should resolve to the registry's default ('mem'),
78
+ // not the plugin's own 'inline' descriptor.
79
+ const { object } = yield* Operation.invoke(
80
+ FileOperation.Create,
81
+ { file: makeFile('data.bin', 'image/png', bytes), db: personalSpace.db },
82
+ { spaceId: personalSpace.id },
83
+ );
84
+
85
+ const blob = yield* Database.load(object.data);
86
+ expect(blob.data._tag).toBe('external');
87
+ expect(blob.data._tag === 'external' && blob.data.uri).toMatch(/^mem:/);
88
+ }),
89
+ );
90
+ } finally {
91
+ cleanup();
92
+ }
40
93
  });
41
94
 
42
- test('accepts video', async ({ expect }) => {
43
- const result = await inlineBackend.upload(makeFile('movie.mp4', 'video/mp4', new Uint8Array(8)), stubDb);
44
- expect(result.type).toBe('video/mp4');
45
- expect(result.data._tag).toBe('inline');
95
+ test('rejects unsupported MIME types', async ({ expect }) => {
96
+ const { harness, personalSpace } = await setup();
97
+ await using _harness = harness;
98
+
99
+ const error = await harness.runPromise(
100
+ Operation.invoke(
101
+ FileOperation.Create,
102
+ { file: makeFile('notes.txt', 'text/plain', new Uint8Array(8)), db: personalSpace.db },
103
+ { spaceId: personalSpace.id },
104
+ ).pipe(Effect.catchAllCause((cause) => Effect.succeed(Cause.squash(cause)))),
105
+ );
106
+ expect(error).toBeInstanceOf(UnsupportedFileTypeError);
46
107
  });
47
108
 
48
- test('rejects text/plain', async ({ expect }) => {
49
- await expect(inlineBackend.upload(makeFile('notes.txt', 'text/plain', new Uint8Array(8)), stubDb)).rejects.toThrow(
50
- UnsupportedFileTypeError,
109
+ test('rejects files larger than the inline cap on the inline backend', async ({ expect }) => {
110
+ const { harness, personalSpace } = await setup();
111
+ await using _harness = harness;
112
+
113
+ const oversized = new Uint8Array(Blob.MAX_INLINE_SIZE + 1);
114
+ const error = await harness.runPromise(
115
+ Operation.invoke(
116
+ FileOperation.Create,
117
+ { file: makeFile('big.png', 'image/png', oversized), db: personalSpace.db },
118
+ { spaceId: personalSpace.id },
119
+ ).pipe(Effect.catchAllCause((cause) => Effect.succeed(Cause.squash(cause)))),
51
120
  );
121
+ expect(error).toBeInstanceOf(FileTooLargeError);
52
122
  });
53
123
  });
124
+
125
+ const makeFile = (name: string, type: string, bytes: Uint8Array): globalThis.File =>
126
+ new globalThis.File([bytes as BlobPart], name, { type });
127
+
128
+ const setup = async () => {
129
+ const harness = await createComposerTestApp({ plugins: [ClientPlugin({}), FilePlugin()] });
130
+ // The node plugin variant omits the browser-only `InlineBackend` module (settings UI, etc.) —
131
+ // contribute the descriptor directly so `resolveActiveStorage` has something to resolve.
132
+ harness.capabilities.contribute({
133
+ module: 'test',
134
+ interface: FileCapabilities.Backend,
135
+ implementation: { name: 'Inline (ECHO)', storage: Blob.Storage.inline },
136
+ });
137
+
138
+ const { personalSpace } = await EffectEx.runAndForwardErrors(
139
+ initializeIdentity(harness.get(ClientCapabilities.Client)),
140
+ );
141
+ await harness.waitForEvent(ClientEvents.SpacesReady);
142
+ return { harness, personalSpace };
143
+ };
@@ -6,8 +6,9 @@ import * as Effect from 'effect/Effect';
6
6
 
7
7
  import { Capabilities, Capability } from '@dxos/app-framework';
8
8
  import { Operation } from '@dxos/compute';
9
+ import { Blob, Database } from '@dxos/echo';
9
10
 
10
- import { File, FileCapabilities, FileOperation, MAX_FILE_SIZE, Settings, isAcceptedMimeType } from '../types';
11
+ import { File, FileCapabilities, FileOperation, Settings, isAcceptedMimeType } from '../types';
11
12
 
12
13
  export class UnsupportedFileTypeError extends Error {
13
14
  constructor(public readonly type: string) {
@@ -19,7 +20,7 @@ export class UnsupportedFileTypeError extends Error {
19
20
  export class FileTooLargeError extends Error {
20
21
  constructor(
21
22
  public readonly size: number,
22
- public readonly limit: number = MAX_FILE_SIZE,
23
+ public readonly limit: number = Blob.MAX_INLINE_SIZE,
23
24
  ) {
24
25
  super(`File is too large: ${size} bytes (limit: ${limit} bytes)`);
25
26
  this.name = 'FileTooLargeError';
@@ -33,12 +34,26 @@ export class NoBackendError extends Error {
33
34
  }
34
35
  }
35
36
 
37
+ export class FileReadError extends Error {
38
+ constructor(cause: unknown) {
39
+ super('Failed to read file contents.');
40
+ this.name = 'FileReadError';
41
+ this.cause = cause;
42
+ }
43
+ }
44
+
36
45
  /**
37
- * Resolve the configured {@link FileCapabilities.Backend} from the plugin
38
- * settings atom. Falls back to the inline backend (or the first registered
39
- * one) if the configured id is missing or settings aren't available.
46
+ * Resolves the storage name to force for an upload:
47
+ * - the storage name the user explicitly configured in Settings.backend, if it matches a
48
+ * registered {@link FileCapabilities.Backend} descriptor
49
+ * - otherwise `undefined`, leaving `File.fromBytes`'s `storage` option unset so it falls through
50
+ * to the Blob registry's own configured default (edge when configured, inline otherwise)
51
+ * instead of hardcoding `inline`
52
+ *
53
+ * Still requires at least one descriptor to be registered, as a sanity check that the plugin's
54
+ * settings UI has something to show.
40
55
  */
41
- export const resolveActiveBackend = Effect.gen(function* () {
56
+ export const resolveActiveStorage = Effect.gen(function* () {
42
57
  const backends = yield* Capability.getAll(FileCapabilities.Backend);
43
58
  if (backends.length === 0) {
44
59
  return yield* Effect.fail(new NoBackendError());
@@ -49,14 +64,15 @@ export const resolveActiveBackend = Effect.gen(function* () {
49
64
 
50
65
  if (settingsAtomOpt._tag === 'Some' && registryOpt._tag === 'Some') {
51
66
  const settings = registryOpt.value.get(settingsAtomOpt.value) as Settings.Settings;
52
- const id = settings.backend ?? Settings.DEFAULT_BACKEND_ID;
53
- const match = backends.find((b) => b.id === id);
54
- if (match) {
55
- return match;
67
+ if (settings.backend) {
68
+ const match = backends.find((b) => b.storage === settings.backend);
69
+ if (match) {
70
+ return match.storage;
71
+ }
56
72
  }
57
73
  }
58
74
 
59
- return backends.find((b) => b.id === Settings.DEFAULT_BACKEND_ID) ?? backends[0];
75
+ return undefined;
60
76
  });
61
77
 
62
78
  const handler: Operation.WithHandler<typeof FileOperation.Create> = FileOperation.Create.pipe(
@@ -66,20 +82,24 @@ const handler: Operation.WithHandler<typeof FileOperation.Create> = FileOperatio
66
82
  if (!isAcceptedMimeType(file.type)) {
67
83
  return yield* Effect.fail(new UnsupportedFileTypeError(file.type));
68
84
  }
69
- if (file.size > MAX_FILE_SIZE) {
70
- return yield* Effect.fail(new FileTooLargeError(file.size));
71
- }
72
- const backend = yield* resolveActiveBackend;
73
- const info = yield* Effect.promise(() => backend.upload(file, db));
74
- return {
75
- object: File.make({
76
- name: info.name,
77
- type: info.type,
78
- size: info.size,
79
- data: info.data,
80
- timestamp: new Date().toISOString(),
85
+ const storage = yield* resolveActiveStorage;
86
+ const bytes = new Uint8Array(
87
+ yield* Effect.tryPromise({
88
+ try: () => file.arrayBuffer(),
89
+ catch: (error) => new FileReadError(error),
81
90
  }),
82
- };
91
+ );
92
+ // The size cap only applies to `inline` storage — `Blob.fromBytes` enforces it internally;
93
+ // other backends scale beyond it.
94
+ const object = yield* File.fromBytes(bytes, {
95
+ name: file.name,
96
+ type: file.type,
97
+ storage,
98
+ }).pipe(
99
+ Effect.provide(Database.layer(db)),
100
+ Effect.catchTag('BlobTooLargeError', () => Effect.fail(new FileTooLargeError(bytes.byteLength))),
101
+ );
102
+ return { object };
83
103
  }),
84
104
  ),
85
105
  );
@@ -3,28 +3,46 @@
3
3
  //
4
4
 
5
5
  import * as Effect from 'effect/Effect';
6
+ import * as Option from 'effect/Option';
6
7
 
7
8
  import { Operation } from '@dxos/compute';
8
- import { Database } from '@dxos/echo';
9
+ import { Blob, Database } from '@dxos/echo';
9
10
  import { ContentBlock } from '@dxos/types';
10
11
 
11
12
  import { FileOperation } from '../types';
12
13
 
14
+ const BASE64_CHUNK_SIZE = 0x8000;
15
+
16
+ // `String.fromCharCode(...bytes)` blows the call stack / argument limit for large arrays, so the
17
+ // bytes are base64-encoded in chunks. `Buffer` isn't available in browser bundles.
18
+ const bytesToBase64 = (bytes: Uint8Array): string => {
19
+ let binary = '';
20
+ for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
21
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
22
+ }
23
+ return btoa(binary);
24
+ };
25
+
13
26
  const handler: Operation.WithHandler<typeof FileOperation.Read> = FileOperation.Read.pipe(
14
27
  Operation.withHandler(
15
28
  Effect.fn(function* ({ file }) {
16
29
  const obj = yield* Database.load(file);
17
- const url =
18
- obj.data._tag === 'inline'
19
- ? `data:${obj.type};base64,${Buffer.from(obj.data.bytes).toString('base64')}`
20
- : obj.data.url;
30
+ const blob = yield* Database.load(obj.data);
31
+ const type = blob.type ?? 'application/octet-stream';
32
+ const urlOption = yield* Blob.url(blob);
33
+ const url = yield* Option.match(urlOption, {
34
+ onSome: Effect.succeed,
35
+ // No renderable URL from the backend (e.g. external storage without `getUrl`) — fall back
36
+ // to reading the bytes directly and encoding them as a `data:` URL.
37
+ onNone: () => Blob.read(blob).pipe(Effect.map((bytes) => `data:${type};base64,${bytesToBase64(bytes)}`)),
38
+ });
21
39
 
22
40
  return ContentBlock.ContentBlockResult.make({
23
41
  content: [
24
42
  ContentBlock.File.make({
25
43
  url,
26
44
  name: obj.name,
27
- mediaType: obj.type,
45
+ mediaType: type,
28
46
  }),
29
47
  ],
30
48
  });
@@ -4,55 +4,34 @@
4
4
 
5
5
  // @import-as-namespace
6
6
 
7
- import { type Atom } from '@effect-atom/atom-react';
7
+ import { type Atom } from '@effect-atom/atom';
8
8
 
9
9
  import { Capability } from '@dxos/app-framework';
10
- import { type Database } from '@dxos/echo';
11
- import { type Space } from '@dxos/react-client/echo';
12
- import { File } from '@dxos/types';
13
10
 
14
11
  import { meta } from '#meta';
15
12
 
16
13
  import * as Settings from './Settings';
17
14
 
18
15
  /**
19
- * A file storage backend. Each backend ingests a File from the user and
20
- * returns the metadata + the {@link File.FileData} to attach to a
21
- * {@link File.File} object.
16
+ * Descriptor for a registered `BlobBackend` storage option, surfaced in the file plugin's
17
+ * settings UI. Actual upload/read logic lives in the Blob backend registered on the Hypergraph
18
+ * under `storage` (see `client.graph.registerBlobBackend`) — this type carries no behavior.
22
19
  */
23
20
  export type Backend = {
24
- /** Stable backend id (e.g. 'inline', 'wnfs'). Used as the settings key. */
25
- readonly id: string;
26
21
  /** Display label shown in the settings UI. */
27
22
  readonly name: string;
28
23
  /** Description shown next to the backend in settings. */
29
24
  readonly description?: string;
30
- /** Upload a file. Throws on validation errors (size cap, unsupported MIME). */
31
- readonly upload: (file: globalThis.File, db: Database.Database) => Promise<UploadResult>;
32
- };
33
-
34
- export type UploadResult = {
35
- readonly name: string;
36
- readonly type: string;
37
- readonly size: number;
38
- readonly data: File.FileData;
25
+ /**
26
+ * `Blob.fromBytes`'s `storage` option to use when this backend is selected. Also the settings
27
+ * key identifying this backend — one `BlobBackend` is registered per storage name, so the two
28
+ * concepts always coincide.
29
+ */
30
+ readonly storage: string;
39
31
  };
40
32
 
41
33
  export const Backend = Capability.make<Backend>(`${meta.profile.key}.capability.backend`);
42
34
 
43
- /**
44
- * Resolves an external URL (e.g. `wnfs://…`) into a renderable URL (a
45
- * `blob:`, `data:`, or `http(s):` URL). Consumers iterate registered
46
- * resolvers and pick the first that returns true from {@link UrlResolver#test}.
47
- */
48
- export type UrlResolver = {
49
- readonly id: string;
50
- readonly test: (url: string) => boolean;
51
- readonly resolve: (url: string, file: File.File, space?: Space) => Promise<string | undefined>;
52
- };
53
-
54
- export const UrlResolver = Capability.make<UrlResolver>(`${meta.profile.key}.capability.url-resolver`);
55
-
56
35
  export const SettingsAtom = Capability.make<Atom.Writable<Settings.Settings>>(
57
36
  `${meta.profile.key}.capability.settings`,
58
37
  );
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as Schema from 'effect/Schema';
8
8
 
9
- export const DEFAULT_BACKEND_ID = 'inline';
9
+ export const DEFAULT_BACKEND_STORAGE = 'inline';
10
10
 
11
11
  export const Settings = Schema.mutable(
12
12
  Schema.Struct({
@@ -2,8 +2,6 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- export const MAX_FILE_SIZE = 4 * 1024 * 1024;
6
-
7
5
  export const ACCEPTED_MIME: Record<string, string[]> = {
8
6
  'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
9
7
  'video/*': ['.mp4', '.webm', '.mov'],