@dxos/plugin-file 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLUGIN.mdl +86 -77
- package/dist/lib/FilePlugin.mjs +37 -0
- package/dist/lib/FilePlugin.mjs.map +1 -0
- package/dist/lib/FilePlugin.node.mjs +12 -0
- package/dist/lib/FilePlugin.node.mjs.map +1 -0
- package/dist/lib/capabilities/node.mjs +8 -0
- package/dist/lib/capabilities/node.mjs.map +1 -0
- package/dist/lib/capabilities.mjs +15 -0
- package/dist/lib/capabilities.mjs.map +1 -0
- package/dist/lib/chunk-FileArticle.mjs +62 -0
- package/dist/lib/chunk-FileArticle.mjs.map +1 -0
- package/dist/lib/chunk-create-object.mjs +28 -0
- package/dist/lib/chunk-create-object.mjs.map +1 -0
- package/dist/lib/chunk-create.mjs +78 -0
- package/dist/lib/chunk-create.mjs.map +1 -0
- package/dist/lib/chunk-edge-backend.mjs +18 -0
- package/dist/lib/chunk-edge-backend.mjs.map +1 -0
- package/dist/lib/chunk-file-uploader.mjs +37 -0
- package/dist/lib/chunk-file-uploader.mjs.map +1 -0
- package/dist/lib/chunk-inline-backend.mjs +19 -0
- package/dist/lib/chunk-inline-backend.mjs.map +1 -0
- package/dist/lib/chunk-markdown-extension.mjs +182 -0
- package/dist/lib/chunk-markdown-extension.mjs.map +1 -0
- package/dist/lib/chunk-meta.mjs +44 -0
- package/dist/lib/chunk-meta.mjs.map +1 -0
- package/dist/lib/chunk-operation-handler.mjs +11 -0
- package/dist/lib/chunk-operation-handler.mjs.map +1 -0
- package/dist/lib/chunk-react-surface.mjs +55 -0
- package/dist/lib/chunk-react-surface.mjs.map +1 -0
- package/dist/lib/chunk-read.mjs +32 -0
- package/dist/lib/chunk-read.mjs.map +1 -0
- package/dist/lib/chunk-settings.mjs +23 -0
- package/dist/lib/chunk-settings.mjs.map +1 -0
- package/dist/lib/chunk-skill-definition.mjs +10 -0
- package/dist/lib/chunk-skill-definition.mjs.map +1 -0
- package/dist/lib/chunk-skills.mjs +28 -0
- package/dist/lib/chunk-skills.mjs.map +1 -0
- package/dist/lib/chunk-types.mjs +98 -0
- package/dist/lib/chunk-types.mjs.map +1 -0
- package/dist/lib/components.mjs +57 -0
- package/dist/lib/components.mjs.map +1 -0
- package/dist/lib/containers.mjs +56 -0
- package/dist/lib/containers.mjs.map +1 -0
- package/dist/lib/index.mjs +4 -0
- package/dist/lib/meta.mjs +2 -0
- package/dist/lib/operations.mjs +7 -0
- package/dist/lib/operations.mjs.map +1 -0
- package/dist/lib/plugin.mjs +9 -0
- package/dist/lib/plugin.mjs.map +1 -0
- package/dist/lib/skills.mjs +2 -0
- package/dist/lib/translations.mjs +30 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/types/src/FilePlugin.d.ts.map +1 -1
- package/dist/types/src/FilePlugin.node.d.ts.map +1 -1
- package/dist/types/src/capabilities/FileUploadField.d.ts +12 -0
- package/dist/types/src/capabilities/FileUploadField.d.ts.map +1 -0
- package/dist/types/src/capabilities/create-object.d.ts +1 -1
- package/dist/types/src/capabilities/edge-backend.d.ts +5 -0
- package/dist/types/src/capabilities/edge-backend.d.ts.map +1 -0
- package/dist/types/src/capabilities/file-uploader.d.ts +1 -1
- package/dist/types/src/capabilities/file-uploader.d.ts.map +1 -1
- package/dist/types/src/capabilities/index.d.ts +1 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/capabilities/inline-backend.d.ts +2 -2
- package/dist/types/src/capabilities/inline-backend.d.ts.map +1 -1
- package/dist/types/src/capabilities/markdown-extension.d.ts +1 -1
- package/dist/types/src/capabilities/node.d.ts +5 -0
- package/dist/types/src/capabilities/node.d.ts.map +1 -0
- package/dist/types/src/capabilities/operation-handler.d.ts +1 -1
- package/dist/types/src/capabilities/react-surface.d.ts +1 -1
- package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
- package/dist/types/src/capabilities/settings.d.ts +1 -1
- package/dist/types/src/components/FileInput/FileInput.d.ts.map +1 -1
- package/dist/types/src/components/FilePreview/FilePreview.d.ts +1 -1
- package/dist/types/src/components/FilePreview/FilePreview.d.ts.map +1 -1
- package/dist/types/src/containers/FileArticle/FileArticle.d.ts +4 -1
- package/dist/types/src/containers/FileArticle/FileArticle.d.ts.map +1 -1
- package/dist/types/src/containers/FileSettings/FileSettings.d.ts +5 -3
- package/dist/types/src/containers/FileSettings/FileSettings.d.ts.map +1 -1
- package/dist/types/src/extensions/image.d.ts +0 -2
- package/dist/types/src/extensions/image.d.ts.map +1 -1
- package/dist/types/src/operations/create.d.ts +14 -5
- package/dist/types/src/operations/create.d.ts.map +1 -1
- package/dist/types/src/operations/read.d.ts.map +1 -1
- package/dist/types/src/types/FileCapabilities.d.ts +10 -28
- package/dist/types/src/types/FileCapabilities.d.ts.map +1 -1
- package/dist/types/src/types/Settings.d.ts +1 -1
- package/dist/types/src/types/Settings.d.ts.map +1 -1
- package/dist/types/src/types/limits.d.ts +0 -1
- package/dist/types/src/types/limits.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dx.config.ts +10 -6
- package/package.json +45 -41
- package/src/FilePlugin.node.ts +2 -1
- package/src/FilePlugin.tsx +8 -1
- package/src/capabilities/FileUploadField.tsx +37 -0
- package/src/capabilities/edge-backend.ts +27 -0
- package/src/capabilities/file-uploader.ts +6 -4
- package/src/capabilities/index.ts +1 -0
- package/src/capabilities/inline-backend.ts +4 -21
- package/src/capabilities/markdown-extension.ts +3 -8
- package/src/capabilities/node.ts +16 -0
- package/src/capabilities/react-surface.ts +46 -0
- package/src/components/FileInput/FileInput.tsx +3 -2
- package/src/containers/FileArticle/FileArticle.tsx +49 -42
- package/src/containers/FileSettings/FileSettings.tsx +19 -14
- package/src/extensions/image.tsx +49 -28
- package/src/operations/create.test.ts +122 -32
- package/src/operations/create.ts +44 -24
- package/src/operations/read.ts +24 -6
- package/src/types/FileCapabilities.ts +10 -31
- package/src/types/Settings.ts +1 -1
- package/src/types/limits.ts +0 -2
- package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs +0 -80
- package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs.map +0 -7
- package/dist/lib/neutral/FilePlugin.mjs +0 -58
- package/dist/lib/neutral/FilePlugin.mjs.map +0 -7
- package/dist/lib/neutral/FilePlugin.node.mjs +0 -23
- package/dist/lib/neutral/FilePlugin.node.mjs.map +0 -7
- package/dist/lib/neutral/capabilities/index.mjs +0 -23
- package/dist/lib/neutral/capabilities/index.mjs.map +0 -7
- package/dist/lib/neutral/chunk-DKGTSUUH.mjs +0 -8
- package/dist/lib/neutral/chunk-DKGTSUUH.mjs.map +0 -7
- package/dist/lib/neutral/chunk-GMYSEHV4.mjs +0 -40
- package/dist/lib/neutral/chunk-GMYSEHV4.mjs.map +0 -7
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/chunk-LBZ4MCLI.mjs +0 -45
- package/dist/lib/neutral/chunk-LBZ4MCLI.mjs.map +0 -7
- package/dist/lib/neutral/chunk-YODPX62X.mjs +0 -136
- package/dist/lib/neutral/chunk-YODPX62X.mjs.map +0 -7
- package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs +0 -80
- package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs.map +0 -7
- package/dist/lib/neutral/components/index.mjs +0 -74
- package/dist/lib/neutral/components/index.mjs.map +0 -7
- package/dist/lib/neutral/containers/index.mjs +0 -59
- package/dist/lib/neutral/containers/index.mjs.map +0 -7
- package/dist/lib/neutral/create-IUUG7RI6.mjs +0 -17
- package/dist/lib/neutral/create-IUUG7RI6.mjs.map +0 -7
- package/dist/lib/neutral/create-object-V667G4ZL.mjs +0 -30
- package/dist/lib/neutral/create-object-V667G4ZL.mjs.map +0 -7
- package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs +0 -41
- package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -32
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs +0 -37
- package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs.map +0 -7
- package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs +0 -230
- package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/meta.mjs +0 -8
- package/dist/lib/neutral/meta.mjs.map +0 -7
- package/dist/lib/neutral/operation-handler-CPDANWGH.mjs +0 -13
- package/dist/lib/neutral/operation-handler-CPDANWGH.mjs.map +0 -7
- package/dist/lib/neutral/operations/index.mjs +0 -8
- package/dist/lib/neutral/operations/index.mjs.map +0 -7
- package/dist/lib/neutral/plugin.mjs +0 -16
- package/dist/lib/neutral/plugin.mjs.map +0 -7
- package/dist/lib/neutral/react-surface-5BXKSHD2.mjs +0 -60
- package/dist/lib/neutral/react-surface-5BXKSHD2.mjs.map +0 -7
- package/dist/lib/neutral/read-HG6CSWKR.mjs +0 -28
- package/dist/lib/neutral/read-HG6CSWKR.mjs.map +0 -7
- package/dist/lib/neutral/settings-RR3RESL2.mjs +0 -28
- package/dist/lib/neutral/settings-RR3RESL2.mjs.map +0 -7
- package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs +0 -15
- package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs.map +0 -7
- package/dist/lib/neutral/skills/index.mjs +0 -11
- package/dist/lib/neutral/skills/index.mjs.map +0 -7
- package/dist/lib/neutral/translations.mjs +0 -36
- package/dist/lib/neutral/translations.mjs.map +0 -7
- package/dist/lib/neutral/types/index.mjs +0 -22
- package/dist/lib/neutral/types/index.mjs.map +0 -7
- 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.
|
|
16
|
+
export type FileSettingsProps = AppSurface.SettingsData;
|
|
16
17
|
|
|
17
|
-
export const FileSettings = ({
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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) =>
|
|
28
|
-
[
|
|
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
|
-
|
|
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={
|
|
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.
|
|
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';
|
package/src/extensions/image.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
19
|
+
import { FileTooLargeError, UnsupportedFileTypeError } from './create';
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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('
|
|
36
|
-
const
|
|
37
|
-
await
|
|
38
|
-
|
|
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('
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
};
|
package/src/operations/create.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
);
|
package/src/operations/read.ts
CHANGED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
readonly
|
|
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
|
);
|
package/src/types/Settings.ts
CHANGED
package/src/types/limits.ts
CHANGED