@dxos/plugin-wnfs 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.
- package/PLUGIN.mdl +92 -46
- package/dist/lib/WnfsPlugin.mjs +28 -0
- package/dist/lib/WnfsPlugin.mjs.map +1 -0
- package/dist/lib/WnfsPlugin.node.mjs +8 -0
- package/dist/lib/WnfsPlugin.node.mjs.map +1 -0
- package/dist/lib/WnfsPlugin.workerd.mjs +8 -0
- package/dist/lib/WnfsPlugin.workerd.mjs.map +1 -0
- package/dist/lib/capabilities.mjs +8 -0
- package/dist/lib/capabilities.mjs.map +1 -0
- package/dist/lib/chunk-blob-backend.mjs +118 -0
- package/dist/lib/chunk-blob-backend.mjs.map +1 -0
- package/dist/lib/chunk-dependencies.mjs +146 -0
- package/dist/lib/chunk-dependencies.mjs.map +1 -0
- package/dist/lib/chunk-helpers.mjs +198 -0
- package/dist/lib/chunk-helpers.mjs.map +1 -0
- package/dist/lib/chunk-meta.mjs +38 -0
- package/dist/lib/chunk-meta.mjs.map +1 -0
- package/dist/lib/chunk-types.mjs +46 -0
- package/dist/lib/chunk-types.mjs.map +1 -0
- package/dist/lib/helpers.mjs +2 -0
- package/dist/lib/index.mjs +4 -0
- package/dist/lib/meta.mjs +2 -0
- package/dist/lib/plugin.mjs +8 -0
- package/dist/lib/plugin.mjs.map +1 -0
- package/dist/lib/translations.mjs +7 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/types/src/WnfsPlugin.d.ts.map +1 -1
- package/dist/types/src/capabilities/blob-backend.d.ts +22 -0
- package/dist/types/src/capabilities/blob-backend.d.ts.map +1 -0
- package/dist/types/src/capabilities/blob-backend.test.d.ts +2 -0
- package/dist/types/src/capabilities/blob-backend.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/dependencies.d.ts +5 -0
- package/dist/types/src/capabilities/dependencies.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +2 -3
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/helpers/wnfs-url.d.ts +6 -0
- package/dist/types/src/helpers/wnfs-url.d.ts.map +1 -1
- package/dist/types/src/types/WnfsCapabilities.d.ts +7 -0
- package/dist/types/src/types/WnfsCapabilities.d.ts.map +1 -1
- package/dist/types/src/types/WnfsEvents.d.ts +11 -0
- package/dist/types/src/types/WnfsEvents.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dx.config.ts +4 -4
- package/package.json +34 -32
- package/src/WnfsPlugin.tsx +9 -24
- package/src/capabilities/blob-backend.test.ts +108 -0
- package/src/capabilities/blob-backend.ts +104 -0
- package/src/capabilities/{blockstore.ts → dependencies.ts} +6 -1
- package/src/capabilities/index.ts +2 -4
- package/src/helpers/wnfs-url.ts +22 -4
- package/src/types/WnfsCapabilities.ts +9 -0
- package/src/types/WnfsEvents.ts +19 -0
- package/src/types/index.ts +1 -0
- package/dist/lib/neutral/WnfsPlugin.mjs +0 -51
- package/dist/lib/neutral/WnfsPlugin.mjs.map +0 -7
- package/dist/lib/neutral/WnfsPlugin.node.mjs +0 -12
- package/dist/lib/neutral/WnfsPlugin.node.mjs.map +0 -7
- package/dist/lib/neutral/WnfsPlugin.workerd.mjs +0 -12
- package/dist/lib/neutral/WnfsPlugin.workerd.mjs.map +0 -7
- package/dist/lib/neutral/backend-4HPTHS33.mjs +0 -47
- package/dist/lib/neutral/backend-4HPTHS33.mjs.map +0 -7
- package/dist/lib/neutral/blockstore-U6SPNL2C.mjs +0 -190
- package/dist/lib/neutral/blockstore-U6SPNL2C.mjs.map +0 -7
- package/dist/lib/neutral/capabilities/index.mjs +0 -13
- package/dist/lib/neutral/capabilities/index.mjs.map +0 -7
- package/dist/lib/neutral/chunk-2WLTMUHV.mjs +0 -19
- package/dist/lib/neutral/chunk-2WLTMUHV.mjs.map +0 -7
- package/dist/lib/neutral/chunk-H2YCLSRI.mjs +0 -47
- package/dist/lib/neutral/chunk-H2YCLSRI.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-L33MOD23.mjs +0 -219
- package/dist/lib/neutral/chunk-L33MOD23.mjs.map +0 -7
- package/dist/lib/neutral/helpers/index.mjs +0 -26
- package/dist/lib/neutral/helpers/index.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -36
- package/dist/lib/neutral/index.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/plugin.mjs +0 -12
- package/dist/lib/neutral/plugin.mjs.map +0 -7
- package/dist/lib/neutral/translations.mjs +0 -17
- package/dist/lib/neutral/translations.mjs.map +0 -7
- package/dist/lib/neutral/types/index.mjs +0 -8
- package/dist/lib/neutral/types/index.mjs.map +0 -7
- package/dist/lib/neutral/url-resolver-BIRH7WHL.mjs +0 -47
- package/dist/lib/neutral/url-resolver-BIRH7WHL.mjs.map +0 -7
- package/dist/types/src/capabilities/backend.d.ts +0 -6
- package/dist/types/src/capabilities/backend.d.ts.map +0 -1
- package/dist/types/src/capabilities/blockstore.d.ts +0 -5
- package/dist/types/src/capabilities/blockstore.d.ts.map +0 -1
- package/dist/types/src/capabilities/url-resolver.d.ts +0 -6
- package/dist/types/src/capabilities/url-resolver.d.ts.map +0 -1
- package/src/capabilities/backend.ts +0 -43
- package/src/capabilities/url-resolver.ts +0 -44
package/dx.config.ts
CHANGED
|
@@ -19,10 +19,10 @@ export default Config2.make({
|
|
|
19
19
|
Uploads and reads are scoped to a DXOS Space; the forest root CID is persisted atomically on the
|
|
20
20
|
space's ECHO properties so the file system state survives across sessions and devices.
|
|
21
21
|
|
|
22
|
-
The plugin registers
|
|
23
|
-
that uses \`plugin-file\` (e.g. \`plugin-
|
|
24
|
-
coupling to the storage implementation. The blockstore automatically queues
|
|
25
|
-
flushes them when connectivity is restored.
|
|
22
|
+
The plugin registers a \`BlobBackend\` on the ECHO Hypergraph (and a matching \`FileCapabilities.Backend\`
|
|
23
|
+
descriptor) so any plugin that uses \`plugin-file\` (e.g. \`plugin-studio\`) can upload files and resolve
|
|
24
|
+
\`wnfs://\` URLs without coupling to the storage implementation. The blockstore automatically queues
|
|
25
|
+
blocks while offline and flushes them when connectivity is restored.
|
|
26
26
|
`,
|
|
27
27
|
icon: { key: 'ph--file-cloud--regular', hue: 'teal' },
|
|
28
28
|
source: 'https://github.com/dxos/dxos/tree/main/packages/plugins/plugin-wnfs',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/plugin-wnfs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "WNFS file management plugin",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
"#capabilities": {
|
|
17
17
|
"source": "./src/capabilities/index.ts",
|
|
18
18
|
"types": "./dist/types/src/capabilities/index.d.ts",
|
|
19
|
-
"default": "./dist/lib/
|
|
19
|
+
"default": "./dist/lib/capabilities.mjs"
|
|
20
20
|
},
|
|
21
21
|
"#helpers": {
|
|
22
22
|
"source": "./src/helpers/index.ts",
|
|
23
23
|
"types": "./dist/types/src/helpers/index.d.ts",
|
|
24
|
-
"default": "./dist/lib/
|
|
24
|
+
"default": "./dist/lib/helpers.mjs"
|
|
25
25
|
},
|
|
26
26
|
"#meta": {
|
|
27
27
|
"source": "./src/meta.ts",
|
|
28
28
|
"types": "./dist/types/src/meta.d.ts",
|
|
29
|
-
"default": "./dist/lib/
|
|
29
|
+
"default": "./dist/lib/meta.mjs"
|
|
30
30
|
},
|
|
31
31
|
"#plugin": {
|
|
32
32
|
"source": {
|
|
@@ -35,37 +35,37 @@
|
|
|
35
35
|
"default": "./src/WnfsPlugin.tsx"
|
|
36
36
|
},
|
|
37
37
|
"types": "./dist/types/src/WnfsPlugin.d.ts",
|
|
38
|
-
"workerd": "./dist/lib/
|
|
39
|
-
"node": "./dist/lib/
|
|
40
|
-
"default": "./dist/lib/
|
|
38
|
+
"workerd": "./dist/lib/WnfsPlugin.workerd.mjs",
|
|
39
|
+
"node": "./dist/lib/WnfsPlugin.node.mjs",
|
|
40
|
+
"default": "./dist/lib/WnfsPlugin.mjs"
|
|
41
41
|
},
|
|
42
42
|
"#translations": {
|
|
43
43
|
"source": "./src/translations.ts",
|
|
44
44
|
"types": "./dist/types/src/translations.d.ts",
|
|
45
|
-
"default": "./dist/lib/
|
|
45
|
+
"default": "./dist/lib/translations.mjs"
|
|
46
46
|
},
|
|
47
47
|
"#types": {
|
|
48
48
|
"source": "./src/types/index.ts",
|
|
49
49
|
"types": "./dist/types/src/types/index.d.ts",
|
|
50
|
-
"default": "./dist/lib/
|
|
50
|
+
"default": "./dist/lib/types.mjs"
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
"exports": {
|
|
54
54
|
".": {
|
|
55
55
|
"source": "./src/index.ts",
|
|
56
56
|
"types": "./dist/types/src/index.d.ts",
|
|
57
|
-
"
|
|
57
|
+
"import": "./dist/lib/index.mjs"
|
|
58
58
|
},
|
|
59
59
|
"./assets/PLUGIN.mdl": "./PLUGIN.mdl",
|
|
60
60
|
"./plugin": {
|
|
61
61
|
"source": "./src/plugin.ts",
|
|
62
62
|
"types": "./dist/types/src/plugin.d.ts",
|
|
63
|
-
"
|
|
63
|
+
"import": "./dist/lib/plugin.mjs"
|
|
64
64
|
},
|
|
65
65
|
"./translations": {
|
|
66
66
|
"source": "./src/translations.ts",
|
|
67
67
|
"types": "./dist/types/src/translations.d.ts",
|
|
68
|
-
"
|
|
68
|
+
"import": "./dist/lib/translations.mjs"
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -87,37 +87,39 @@
|
|
|
87
87
|
"multiformats": "^13.2.2",
|
|
88
88
|
"uint8arrays": "^5.1.0",
|
|
89
89
|
"wnfs": "0.2.2",
|
|
90
|
-
"@dxos/app-
|
|
91
|
-
"@dxos/
|
|
92
|
-
"@dxos/
|
|
93
|
-
"@dxos/echo": "0.
|
|
94
|
-
"@dxos/
|
|
95
|
-
"@dxos/
|
|
96
|
-
"@dxos/
|
|
97
|
-
"@dxos/
|
|
98
|
-
"@dxos/
|
|
99
|
-
"@dxos/react-
|
|
100
|
-
"@dxos/
|
|
101
|
-
"@dxos/
|
|
102
|
-
"@dxos/
|
|
90
|
+
"@dxos/app-toolkit": "0.11.0",
|
|
91
|
+
"@dxos/client": "0.11.0",
|
|
92
|
+
"@dxos/echo": "0.11.0",
|
|
93
|
+
"@dxos/echo-protocol": "0.11.0",
|
|
94
|
+
"@dxos/crypto": "0.11.0",
|
|
95
|
+
"@dxos/invariant": "0.11.0",
|
|
96
|
+
"@dxos/keys": "0.11.0",
|
|
97
|
+
"@dxos/log": "0.11.0",
|
|
98
|
+
"@dxos/plugin-client": "0.11.0",
|
|
99
|
+
"@dxos/react-client": "0.11.0",
|
|
100
|
+
"@dxos/react-ui": "0.11.0",
|
|
101
|
+
"@dxos/types": "0.11.0",
|
|
102
|
+
"@dxos/plugin-file": "0.11.0",
|
|
103
|
+
"@dxos/util": "0.11.0",
|
|
104
|
+
"@dxos/app-framework": "0.11.0"
|
|
103
105
|
},
|
|
104
106
|
"devDependencies": {
|
|
105
107
|
"@types/react": "~19.2.17",
|
|
106
108
|
"@types/react-dom": "~19.2.3",
|
|
107
109
|
"react": "~19.2.7",
|
|
108
110
|
"react-dom": "~19.2.7",
|
|
109
|
-
"vite": "^8.
|
|
110
|
-
"@dxos/plugin-testing": "0.
|
|
111
|
-
"@dxos/
|
|
112
|
-
"@dxos/ui-theme": "0.
|
|
113
|
-
"@dxos/
|
|
111
|
+
"vite": "^8.1.4",
|
|
112
|
+
"@dxos/plugin-testing": "0.11.0",
|
|
113
|
+
"@dxos/storybook-utils": "0.11.0",
|
|
114
|
+
"@dxos/ui-theme": "0.11.0",
|
|
115
|
+
"@dxos/random": "0.11.0"
|
|
114
116
|
},
|
|
115
117
|
"peerDependencies": {
|
|
116
118
|
"effect": "3.21.4",
|
|
117
119
|
"react": "~19.2.7",
|
|
118
120
|
"react-dom": "~19.2.7",
|
|
119
|
-
"@dxos/react-ui": "0.
|
|
120
|
-
"@dxos/ui-theme": "0.
|
|
121
|
+
"@dxos/react-ui": "^0.11.0",
|
|
122
|
+
"@dxos/ui-theme": "^0.11.0"
|
|
121
123
|
},
|
|
122
124
|
"publishConfig": {
|
|
123
125
|
"access": "public"
|
package/src/WnfsPlugin.tsx
CHANGED
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import { Capability, Plugin } from '@dxos/app-framework';
|
|
5
|
+
import { Plugin } from '@dxos/app-framework';
|
|
8
6
|
import { AppPlugin } from '@dxos/app-toolkit';
|
|
9
7
|
import { ClientEvents } from '@dxos/plugin-client';
|
|
10
8
|
|
|
11
|
-
import {
|
|
9
|
+
import { BlobBackend, Dependencies } from '#capabilities';
|
|
12
10
|
import { meta } from '#meta';
|
|
13
11
|
import { translations } from '#translations';
|
|
14
|
-
import {
|
|
12
|
+
import { WnfsEvents } from '#types';
|
|
15
13
|
|
|
16
14
|
// eslint-disable-next-line import/no-relative-packages
|
|
17
15
|
import pluginSpec from '../PLUGIN.mdl?raw';
|
|
@@ -19,28 +17,15 @@ import pluginSpec from '../PLUGIN.mdl?raw';
|
|
|
19
17
|
export const WnfsPlugin = Plugin.define(meta).pipe(
|
|
20
18
|
AppPlugin.addTranslationsModule({ translations }),
|
|
21
19
|
Plugin.addModule({
|
|
22
|
-
id: '
|
|
23
|
-
activatesOn: ClientEvents.ClientReady,
|
|
24
|
-
activate: Blockstore,
|
|
25
|
-
}),
|
|
26
|
-
Plugin.addModule({
|
|
27
|
-
id: 'instances',
|
|
20
|
+
id: 'dependencies',
|
|
28
21
|
activatesOn: ClientEvents.ClientReady,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const instances: WnfsCapabilities.Instances = {};
|
|
32
|
-
return Capability.contributes(WnfsCapabilities.Instances, instances);
|
|
33
|
-
}),
|
|
22
|
+
firesAfterActivation: [WnfsEvents.DependenciesReady],
|
|
23
|
+
activate: Dependencies,
|
|
34
24
|
}),
|
|
35
25
|
Plugin.addModule({
|
|
36
|
-
id: 'backend',
|
|
37
|
-
activatesOn:
|
|
38
|
-
activate:
|
|
39
|
-
}),
|
|
40
|
-
Plugin.addModule({
|
|
41
|
-
id: 'url-resolver',
|
|
42
|
-
activatesOn: ClientEvents.ClientReady,
|
|
43
|
-
activate: UrlResolver,
|
|
26
|
+
id: 'blob-backend',
|
|
27
|
+
activatesOn: WnfsEvents.DependenciesReady,
|
|
28
|
+
activate: BlobBackend,
|
|
44
29
|
}),
|
|
45
30
|
AppPlugin.addPluginAssetModule({
|
|
46
31
|
asset: { pluginId: meta.profile.key, path: 'PLUGIN.mdl', content: pluginSpec, mimeType: 'application/x-mdl' },
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { type Client } from '@dxos/client';
|
|
8
|
+
import { SpaceId } from '@dxos/keys';
|
|
9
|
+
|
|
10
|
+
import { WnfsCapabilities } from '#types';
|
|
11
|
+
|
|
12
|
+
vi.mock('../helpers', () => ({
|
|
13
|
+
loadWnfs: vi.fn(),
|
|
14
|
+
readWnfsFile: vi.fn(),
|
|
15
|
+
upload: vi.fn(),
|
|
16
|
+
getBlobUrl: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { getBlobUrl, loadWnfs, readWnfsFile, upload } from '../helpers';
|
|
20
|
+
import { createWnfsBlobBackend } from './blob-backend';
|
|
21
|
+
|
|
22
|
+
describe('createWnfsBlobBackend', () => {
|
|
23
|
+
// Test doubles: `Client`/`Space`/`Blockstore`/`PrivateDirectory`/`PrivateForest` are only ever
|
|
24
|
+
// passed through to the mocked `../helpers` functions below, never inspected by the backend
|
|
25
|
+
// itself, so minimal stand-ins are sufficient.
|
|
26
|
+
const spaceId = SpaceId.random();
|
|
27
|
+
const space = { id: spaceId } as any;
|
|
28
|
+
const client = { spaces: { get: (id: SpaceId) => (id === spaceId ? space : undefined) } } as unknown as Client;
|
|
29
|
+
const blockstore = {} as any;
|
|
30
|
+
|
|
31
|
+
test('schemes includes the wnfs scheme', ({ expect }) => {
|
|
32
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
33
|
+
expect(backend.schemes).toContain(WnfsCapabilities.WNFS_SCHEME);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('put uploads via the upload helper and returns its url as the URI', async ({ expect }) => {
|
|
37
|
+
vi.mocked(upload).mockResolvedValueOnce({
|
|
38
|
+
name: 'photo.png',
|
|
39
|
+
type: 'image/png',
|
|
40
|
+
url: 'wnfs://spaces/x/files/abc',
|
|
41
|
+
cid: 'abc',
|
|
42
|
+
});
|
|
43
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
44
|
+
|
|
45
|
+
const response = await backend.put({
|
|
46
|
+
spaceId,
|
|
47
|
+
data: new Uint8Array([1, 2, 3]),
|
|
48
|
+
contentType: 'image/png',
|
|
49
|
+
contentHash: 'abc',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(response.uri).toBe('wnfs://spaces/x/files/abc');
|
|
53
|
+
expect(upload).toHaveBeenCalledWith(expect.objectContaining({ blockstore, space }));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('get returns undefined when the space is not found', async ({ expect }) => {
|
|
57
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
58
|
+
const result = await backend.get({ spaceId: SpaceId.random(), uri: 'wnfs://x' });
|
|
59
|
+
expect(result).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('get reads bytes via loadWnfs + readWnfsFile', async ({ expect }) => {
|
|
63
|
+
const directory = {} as any;
|
|
64
|
+
const forest = {} as any;
|
|
65
|
+
vi.mocked(loadWnfs).mockResolvedValueOnce({ directory, forest });
|
|
66
|
+
vi.mocked(readWnfsFile).mockResolvedValueOnce(new Uint8Array([9, 8, 7]));
|
|
67
|
+
|
|
68
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
69
|
+
const result = await backend.get({ spaceId, uri: 'wnfs://spaces/x/files/abc' });
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual(new Uint8Array([9, 8, 7]));
|
|
72
|
+
expect(readWnfsFile).toHaveBeenCalledWith(
|
|
73
|
+
expect.objectContaining({ wnfsUrl: 'wnfs://spaces/x/files/abc', directory, forest }),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('get returns undefined when reading throws', async ({ expect }) => {
|
|
78
|
+
vi.mocked(loadWnfs).mockRejectedValueOnce(new Error('boom'));
|
|
79
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
80
|
+
const result = await backend.get({ spaceId, uri: 'wnfs://spaces/x/files/missing' });
|
|
81
|
+
expect(result).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('has reflects whether get resolves bytes', async ({ expect }) => {
|
|
85
|
+
vi.mocked(loadWnfs).mockResolvedValueOnce({ directory: {} as any, forest: {} as any });
|
|
86
|
+
vi.mocked(readWnfsFile).mockResolvedValueOnce(new Uint8Array([1]));
|
|
87
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
88
|
+
expect(await backend.has({ spaceId, uri: 'wnfs://spaces/x/files/abc' })).toBe(true);
|
|
89
|
+
|
|
90
|
+
vi.mocked(loadWnfs).mockRejectedValueOnce(new Error('boom'));
|
|
91
|
+
expect(await backend.has({ spaceId, uri: 'wnfs://spaces/x/files/missing' })).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('getUrl resolves via the getBlobUrl helper', async ({ expect }) => {
|
|
95
|
+
const directory = {} as any;
|
|
96
|
+
const forest = {} as any;
|
|
97
|
+
vi.mocked(loadWnfs).mockResolvedValueOnce({ directory, forest });
|
|
98
|
+
vi.mocked(getBlobUrl).mockResolvedValueOnce('blob:http://local/abc');
|
|
99
|
+
|
|
100
|
+
const backend = createWnfsBlobBackend({ client, blockstore });
|
|
101
|
+
const url = await backend.getUrl!({ spaceId, uri: 'wnfs://spaces/x/files/abc', contentType: 'image/png' });
|
|
102
|
+
|
|
103
|
+
expect(url).toBe('blob:http://local/abc');
|
|
104
|
+
expect(getBlobUrl).toHaveBeenCalledWith(
|
|
105
|
+
expect.objectContaining({ wnfsUrl: 'wnfs://spaces/x/files/abc', type: 'image/png' }),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import type { Blockstore } from 'interface-blockstore';
|
|
7
|
+
|
|
8
|
+
import { Capability } from '@dxos/app-framework';
|
|
9
|
+
import { type Client } from '@dxos/client';
|
|
10
|
+
import { type BlobBackend } from '@dxos/echo-protocol';
|
|
11
|
+
import { invariant } from '@dxos/invariant';
|
|
12
|
+
import { type SpaceId } from '@dxos/keys';
|
|
13
|
+
import { log } from '@dxos/log';
|
|
14
|
+
import { ClientCapabilities } from '@dxos/plugin-client';
|
|
15
|
+
import { FileCapabilities } from '@dxos/plugin-file/types';
|
|
16
|
+
|
|
17
|
+
import { WnfsCapabilities } from '#types';
|
|
18
|
+
|
|
19
|
+
import { getBlobUrl, loadWnfs, readWnfsFile, upload } from '../helpers';
|
|
20
|
+
|
|
21
|
+
interface CreateWnfsBlobBackendOptions {
|
|
22
|
+
client: Client;
|
|
23
|
+
blockstore: Blockstore;
|
|
24
|
+
instances?: WnfsCapabilities.Instances;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Blob backend that stores bytes in a space's WNFS private forest, addressed by `wnfs://…` URIs.
|
|
29
|
+
* The underlying WASM bindings (`directory.read`) don't expose a distinct not-found error type, so
|
|
30
|
+
* `readBytes`/`getUrl` can't tell a missing path from a genuine transport/blockstore failure —
|
|
31
|
+
* both are logged and reported as a miss (`undefined`) rather than risk rethrowing on the common
|
|
32
|
+
* missing-file path.
|
|
33
|
+
*/
|
|
34
|
+
export const createWnfsBlobBackend = ({ client, blockstore, instances }: CreateWnfsBlobBackendOptions): BlobBackend => {
|
|
35
|
+
const readBytes = async (spaceId: SpaceId, uri: string) => {
|
|
36
|
+
const space = client.spaces.get(spaceId);
|
|
37
|
+
if (!space) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const { directory, forest } = await loadWnfs({ blockstore, instances, space });
|
|
42
|
+
return await readWnfsFile({ wnfsUrl: uri, blockstore, forest, directory });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
log.warn('failed to read wnfs blob', { spaceId, uri, error });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
schemes: [WnfsCapabilities.WNFS_SCHEME],
|
|
51
|
+
|
|
52
|
+
put: async ({ spaceId, data, contentType, contentHash, name }) => {
|
|
53
|
+
const space = client.spaces.get(spaceId);
|
|
54
|
+
invariant(space, 'Space not found');
|
|
55
|
+
// `Uint8Array` is generic over `ArrayBufferLike` (incl. `SharedArrayBuffer`) while DOM's
|
|
56
|
+
// `BlobPart` only covers `ArrayBuffer`-backed views — a gap between the DOM lib types and
|
|
57
|
+
// the TS standard lib, not fixable by typing `data` differently.
|
|
58
|
+
const file = new File([data as BlobPart], name ?? contentHash, { type: contentType });
|
|
59
|
+
const info = await upload({ file, blockstore, instances, space });
|
|
60
|
+
return { uri: info.url };
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
get: async ({ spaceId, uri }) => readBytes(spaceId, uri),
|
|
64
|
+
|
|
65
|
+
has: async ({ spaceId, uri }) => (await readBytes(spaceId, uri)) !== undefined,
|
|
66
|
+
|
|
67
|
+
getUrl: async ({ spaceId, uri, contentType }) => {
|
|
68
|
+
const space = client.spaces.get(spaceId);
|
|
69
|
+
if (!space) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const { directory, forest } = await loadWnfs({ blockstore, instances, space });
|
|
74
|
+
return await getBlobUrl({ wnfsUrl: uri, blockstore, directory, forest, type: contentType });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
log.warn('failed to resolve wnfs blob url', { spaceId, uri, error });
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default Capability.makeModule(
|
|
84
|
+
Effect.fnUntraced(function* () {
|
|
85
|
+
const client = yield* Capability.get(ClientCapabilities.Client);
|
|
86
|
+
const blockstore = yield* Capability.get(WnfsCapabilities.Blockstore);
|
|
87
|
+
const instances = yield* Capability.get(WnfsCapabilities.Instances);
|
|
88
|
+
|
|
89
|
+
const cleanup = client.graph.registerBlobBackend(
|
|
90
|
+
WnfsCapabilities.WNFS_BACKEND,
|
|
91
|
+
createWnfsBlobBackend({ client, blockstore, instances }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return Capability.contributes(
|
|
95
|
+
FileCapabilities.Backend,
|
|
96
|
+
{
|
|
97
|
+
name: 'WNFS',
|
|
98
|
+
description: 'Decentralized, end-to-end encrypted storage via Web Native File System.',
|
|
99
|
+
storage: WnfsCapabilities.WNFS_BACKEND,
|
|
100
|
+
},
|
|
101
|
+
() => Effect.sync(() => cleanup()),
|
|
102
|
+
);
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
@@ -18,6 +18,11 @@ export default Capability.makeModule(
|
|
|
18
18
|
const blockstore = Blockstore.create(apiHost);
|
|
19
19
|
yield* Effect.tryPromise(() => blockstore.open());
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
const instances: WnfsCapabilities.Instances = {};
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
Capability.contributes(WnfsCapabilities.Blockstore, blockstore),
|
|
25
|
+
Capability.contributes(WnfsCapabilities.Instances, instances),
|
|
26
|
+
];
|
|
22
27
|
}),
|
|
23
28
|
);
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { Capability } from '@dxos/app-framework';
|
|
6
6
|
|
|
7
|
-
export const
|
|
7
|
+
export const BlobBackend = Capability.lazy('BlobBackend', () => import('./blob-backend'));
|
|
8
8
|
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
-
export const UrlResolver = Capability.lazy('UrlResolver', () => import('./url-resolver'));
|
|
9
|
+
export const Dependencies = Capability.lazy('Dependencies', () => import('./dependencies'));
|
package/src/helpers/wnfs-url.ts
CHANGED
|
@@ -17,6 +17,23 @@ export const getPathFromUrl = (wnfsUrl: string) =>
|
|
|
17
17
|
.split('/')
|
|
18
18
|
.map((p) => decodeURIComponent(p));
|
|
19
19
|
|
|
20
|
+
export const readWnfsFile = async ({
|
|
21
|
+
wnfsUrl,
|
|
22
|
+
blockstore,
|
|
23
|
+
forest,
|
|
24
|
+
directory,
|
|
25
|
+
}: {
|
|
26
|
+
wnfsUrl: string;
|
|
27
|
+
blockstore: Blockstore;
|
|
28
|
+
forest: PrivateForest;
|
|
29
|
+
directory: PrivateDirectory;
|
|
30
|
+
}): Promise<Uint8Array> => {
|
|
31
|
+
const path = getPathFromUrl(wnfsUrl);
|
|
32
|
+
const wnfsStore = store(blockstore);
|
|
33
|
+
const { result } = await directory.read(path, true, forest, wnfsStore);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
|
|
20
37
|
export const getBlobUrl = async ({
|
|
21
38
|
wnfsUrl,
|
|
22
39
|
blockstore,
|
|
@@ -30,9 +47,10 @@ export const getBlobUrl = async ({
|
|
|
30
47
|
directory: PrivateDirectory;
|
|
31
48
|
type?: string;
|
|
32
49
|
}) => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
const result = await readWnfsFile({ wnfsUrl, blockstore, forest, directory });
|
|
51
|
+
// `Uint8Array` is generic over `ArrayBufferLike` (incl. `SharedArrayBuffer`) while DOM's
|
|
52
|
+
// `BlobPart` only covers `ArrayBuffer`-backed views — a gap between the DOM lib types and the
|
|
53
|
+
// TS standard lib, not fixable by typing `result` differently.
|
|
54
|
+
const blob = new Blob([result as BlobPart], { type });
|
|
37
55
|
return URL.createObjectURL(blob);
|
|
38
56
|
};
|
|
@@ -16,3 +16,12 @@ export const Blockstore = Capability.make<BlockstoreInterface>(`${meta.profile.k
|
|
|
16
16
|
|
|
17
17
|
export type Instances = Record<SpaceId, { directory: PrivateDirectory; forest: PrivateForest }>;
|
|
18
18
|
export const Instances = Capability.make<Instances>(`${meta.profile.key}.capability.instances`);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Name this extension registers its `BlobBackend` under on the ECHO Hypergraph. Core packages
|
|
22
|
+
* (`@dxos/echo`, `@dxos/echo-client`, `@dxos/client`) never reference this constant.
|
|
23
|
+
*/
|
|
24
|
+
export const WNFS_BACKEND = 'wnfs';
|
|
25
|
+
|
|
26
|
+
/** URI scheme this extension's `BlobBackend` resolves at read time. */
|
|
27
|
+
export const WNFS_SCHEME = 'wnfs';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import { ActivationEvent } from '@dxos/app-framework';
|
|
8
|
+
|
|
9
|
+
import { meta } from '#meta';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fired by the `dependencies` module (via `firesAfterActivation`) once it finishes contributing
|
|
13
|
+
* `WnfsCapabilities.Blockstore`/`Instances`. `blob-backend` activates on this event rather than
|
|
14
|
+
* `ClientEvents.ClientReady` directly — `dependencies` also activates on `ClientReady`, and
|
|
15
|
+
* modules activating on the same event load concurrently with capabilities invisible to each
|
|
16
|
+
* other until the whole batch settles, so `blob-backend` couldn't otherwise be guaranteed to run
|
|
17
|
+
* after `dependencies`.
|
|
18
|
+
*/
|
|
19
|
+
export const DependenciesReady = ActivationEvent.make(`${meta.profile.key}.event.dependenciesReady`);
|
package/src/types/index.ts
CHANGED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import "./chunk-J5LGTIGS.mjs";
|
|
2
|
-
|
|
3
|
-
// src/WnfsPlugin.tsx
|
|
4
|
-
import * as Effect from "effect/Effect";
|
|
5
|
-
import { Capability, Plugin } from "@dxos/app-framework";
|
|
6
|
-
import { AppPlugin } from "@dxos/app-toolkit";
|
|
7
|
-
import { ClientEvents } from "@dxos/plugin-client";
|
|
8
|
-
import { Backend, Blockstore, UrlResolver } from "#capabilities";
|
|
9
|
-
import { meta } from "#meta";
|
|
10
|
-
import { translations } from "#translations";
|
|
11
|
-
import { WnfsCapabilities } from "#types";
|
|
12
|
-
|
|
13
|
-
// raw-loader:/__w/dxos/dxos/packages/plugins/plugin-wnfs/PLUGIN.mdl
|
|
14
|
-
var PLUGIN_default = "---\nid: org.dxos.plugin.wnfs\nname: WnfsPlugin\nversion: 0.1.0\n---\n\nA decentralized file storage plugin for `DXOS` Composer built on the Web Native File System (WNFS) protocol.\n\nWNFS provides a content-addressed, end-to-end encrypted private file system using a cryptographic \"dark forest\"\nstructure. Files are stored as IPLD blocks referenced by CID, replicated to a DXOS edge blob service, and\ncached locally in IndexedDB. The plugin implements the `FileCapabilities.Backend` and `FileCapabilities.UrlResolver`\ninterfaces from `plugin-file`, so other plugins (e.g. `plugin-gallery`) can upload and resolve `wnfs://` URLs\nwithout knowing the underlying storage layer. Uploads are scoped to a DXOS Space; the private forest root CID\nis persisted on the space's ECHO properties and updated atomically on every write.\n\n## Extensions\n\nThe following extension dialects are used in this document.\nEach extension is defined in the Appendix or resolved via its URI.\n\n| Term | URI |\n|-------------|--------------------------------|\n| `type` | `org.dxos.mdl.type@1.0` |\n| `feat` | `org.dxos.mdl.feat@1.0` |\n| `test` | `org.dxos.mdl.test@1.0` |\n| `component` | `org.dxos.mdl.component@1.0` |\n| `op` | `org.dxos.mdl.op@1.0` |\n\n## Types\n\n```mdl\ntype Cid\n desc: A content-addressed identifier encoded as a base-32 multibase string (CIDv1, sha2-256).\n fields:\n value: string\n```\n\n```mdl\ntype WnfsPath\n desc: A WNFS path encoded as a wnfs:// URL; segments are percent-encoded.\n fields:\n url: string # wnfs://spaces/<spaceId>/files/<cid>\n```\n\n```mdl\ntype FileInfo\n desc: Metadata returned after a successful upload.\n fields:\n name: string # Original file name\n type: string # MIME type\n url: WnfsPath # wnfs:// URL for later retrieval\n cid: Cid # Raw content CID of the file bytes\n```\n\n```mdl\ntype BlockstoreState\n desc: Runtime state of the MixedBlockstore.\n fields:\n isConnected: boolean # Online / offline status\n queueLength: number # Blocks pending remote flush\n apiHost: string # DXOS edge service base URL\n```\n\n```mdl\ntype WnfsInstances\n desc: In-memory cache mapping forest CID \u2192 loaded PrivateDirectory/PrivateForest pair.\n fields:\n entries: Record<string, WnfsInstance>\n```\n\n```mdl\ntype WnfsInstance\n desc: A loaded WNFS private file system rooted at a specific forest CID.\n fields:\n directory: PrivateDirectory\n forest: PrivateForest\n```\n```\n\n## Operations\n\nPure operations \u2014 no external effects.\n\n```mdl\nop wnfsUrl\n desc: Encodes a WNFS path array into a wnfs:// URL with percent-encoded segments.\n input:\n filePath: string[]\n output: string\n```\n\n```mdl\nop getPathFromUrl\n desc: Decodes a wnfs:// URL back into a path segment array.\n input:\n wnfsUrl: string\n output: string[]\n```\n\nEffectful operations \u2014 read or write persistent state.\n\n```mdl\nop uploadFile\n desc: |\n Reads a File, writes its bytes into the WNFS private directory at a\n CID-derived path, persists the updated forest pointer to the space's\n ECHO properties, and returns FileInfo.\n input:\n file: File\n space: Space\n output: FileInfo\n effects: [fs:read, echo:write, http:post]\n errors:\n SpaceNotFound: no space matching the given spaceId\n BlockstoreError: local or remote block I/O failure\n note: |\n Uses sha256 for block hashing (not the rs-wnfs default Blake3) for\n broader runtime support. Writes are atomic from ECHO's perspective:\n the forest CID property is only updated after the forest is stored.\n```\n\n```mdl\nop resolveWnfsUrl\n desc: |\n Reads file bytes from the WNFS private directory for a wnfs:// URL,\n wraps them in a Blob, and returns an object URL for in-browser display.\n input:\n wnfsUrl: string\n space: Space\n type?: string # MIME type hint for Blob construction\n output: string # blob: object URL\n effects: [fs:read, http:get]\n errors:\n UrlNotResolvable: space not available or file not found in directory\n note: Errors are swallowed and undefined is returned to avoid breaking image renders.\n```\n\n```mdl\nop flushBlockQueue\n desc: |\n Drains the pending-upload queue by assembling a CAR archive and POSTing\n it to the edge blob service. Called automatically (debounced 5 s) on\n block writes and on the browser `online` event.\n input:\n blockstore: Blockstore\n output: void\n effects: [http:post]\n errors:\n NetworkError: remote POST failed\n note: Queue is persisted to IndexedDB so pending blocks survive page reloads.\n```\n\n## Features\n\n```mdl\nfeat F-1: Upload File\n\n req F-1.1:\n when: a FileCapabilities.Backend consumer calls upload(file, db)\n then: the file is read, hashed, written to the WNFS private directory,\n and stored in the blockstore\n\n req F-1.2:\n when: the write succeeds\n then: the space's ECHO properties are updated with the new privateForestCid\n\n req F-1.3:\n when: the upload completes\n then: a FileInfo object with name, type, size, and wnfs:// URL is returned to the caller\n```\n\n```mdl\nfeat F-2: Resolve wnfs:// URL\n\n req F-2.1:\n when: FileCapabilities.UrlResolver.test(url) is called with a wnfs:// URL\n then: returns true\n\n req F-2.2:\n when: FileCapabilities.UrlResolver.resolve(url, file, space) is called\n then: loads or reuses a cached WnfsInstance for the space and reads the file bytes\n\n req F-2.3:\n when: bytes are successfully read\n then: a blob: object URL is returned for in-browser display\n\n req F-2.4:\n when: the space is undefined or reading fails\n then: undefined is returned; no error is propagated\n```\n\n```mdl\nfeat F-3: Blockstore Sync\n\n req F-3.1:\n when: a block is written via blockstore.put\n then: the block is saved to IndexedDB and added to the upload queue\n\n req F-3.2:\n when: the browser is online and the queue is non-empty\n then: blocks are batched into a CAR archive and flushed to the edge service\n\n req F-3.3:\n when: the browser goes offline\n then: the queue is persisted to IndexedDB; no upload is attempted\n\n req F-3.4:\n when: the browser comes back online\n then: the flush is triggered automatically\n\n req F-3.5:\n when: a block is requested via blockstore.get and is present in IndexedDB\n then: the block is returned from the local cache without a network request\n\n req F-3.6:\n when: a block is not in IndexedDB and the browser is online\n then: the block is fetched from the edge service and cached locally\n```\n\n```mdl\nfeat F-4: Space-Scoped Private File System\n\n req F-4.1:\n when: a space has no existing WNFS properties\n then: a new PrivateForest and PrivateDirectory are created and stored\n\n req F-4.2:\n when: a space already has a privateForestCid\n then: the existing forest and directory are loaded from the blockstore\n\n req F-4.3:\n when: a WnfsInstance is already cached for the current forest CID\n then: the cached instance is reused without reloading from the blockstore\n\n req F-4.4:\n when: legacy snake_case WNFS properties are found on a space\n then: they are deleted and replaced with the current camelCase schema\n tags: [migration]\n```\n\n## Acceptance\n\n```mdl\ntest T-1: Upload creates FileInfo\n given: an active space and a registered WNFS backend\n when: a file is uploaded via the Backend capability\n then:\n - FileInfo.url starts with wnfs://\n - space.properties.wnfs.privateForestCid is set\n - block for the file is present in the local IndexedDB blockstore\n```\n\n```mdl\ntest T-2: Resolve uploaded file\n given: a file was previously uploaded and FileInfo.url is known\n when: UrlResolver.resolve(url, file, space) is called\n then:\n - a blob: object URL is returned\n - URL can be used as an img src\n```\n\n```mdl\ntest T-3: Resolve without space returns undefined\n given: a wnfs:// URL\n when: UrlResolver.resolve is called with space = undefined\n then: undefined is returned; no error is thrown\n```\n\n```mdl\ntest T-4: Blockstore offline queuing\n given: the browser is offline\n when: a block is written to the blockstore\n then:\n - block is saved to IndexedDB\n - queue length increases by 1\n - no HTTP request is made\n```\n\n```mdl\ntest T-5: Blockstore queue flushes on reconnect\n given: blocks are queued while offline\n when: the browser comes back online\n then:\n - a CAR archive containing all queued blocks is POSTed to the edge service\n - the queue is cleared\n```\n\n```mdl\ntest T-6: New space initializes WNFS directory\n given: a space with no WNFS properties\n when: loadWnfs is called for that space\n then:\n - a new PrivateForest and PrivateDirectory are created\n - space.properties.wnfs.privateForestCid is populated\n```\n\n```mdl\ntest T-7: WnfsInstance cache hit\n given: a WnfsInstance is cached for the current forest CID\n when: loadWnfs is called again for the same space\n then: the cached instance is returned; no blockstore reads occur\n```\n\n---\n\n## Appendix: Extension Definitions\n\nExtension block types used in this document are defined below using\nthe core `ext` primitive \u2014 the only construct the base language provides.\n\n```mdl\next type\n uri: org.dxos.mdl.type@1.0\n desc: A named data structure with typed fields and optional literals.\n fields:\n desc?: Prose\n fields?: FieldMap # name[?]: TypeExpr (# inline comment)\n literals?: UnionList # a | b | c\n extends?: TypeRef[]\n```\n\n```mdl\next feat\n uri: org.dxos.mdl.feat@1.0\n desc: A named feature grouping one or more requirements.\n fields:\n desc?: Prose\n req: RequirementList\n nesting: self # feat blocks may contain feat blocks\n```\n\n```mdl\next test\n uri: org.dxos.mdl.test@1.0\n desc: An acceptance scenario expressed as given / when / then steps.\n fields:\n given?: Step | Step[]\n when?: Step | Step[]\n then: Step | Step[]\n tags?: TagList\n```\n\n```mdl\next component\n uri: org.dxos.mdl.component@1.0\n desc: A UI component with props, internal state, slots, actions, and events.\n fields:\n desc?: Prose\n props?: FieldMap # external inputs (immutable inside component)\n state?: FieldMap # internal reactive state\n slots?: FieldMap # named ReactNode injection points\n actions?: ActionMap # methods the component exposes or handles\n emits?: EventMap # events the component raises to its parent\n layout?: CodeBlock # ASCII sketch of visual structure (non-normative)\n```\n\n```mdl\next op\n uri: org.dxos.mdl.op@1.0\n desc: |\n A named operation with typed inputs, outputs, and declared errors.\n Pure ops have no effects or requires. Effectful ops declare both.\n fields:\n desc?: Prose\n input?: FieldMap # named input parameters\n output?: TypeExpr # return type\n errors?: ErrorMap # name: Prose (when this error occurs)\n effects?: EffectList # echo:read | echo:write | http | fs | ...\n requires?: ServiceList # injected service dependencies\n note?: Prose # implementation guidance (non-normative)\n```\n";
|
|
15
|
-
|
|
16
|
-
// src/WnfsPlugin.tsx
|
|
17
|
-
var WnfsPlugin = Plugin.define(meta).pipe(AppPlugin.addTranslationsModule({
|
|
18
|
-
translations
|
|
19
|
-
}), Plugin.addModule({
|
|
20
|
-
id: "blockstore",
|
|
21
|
-
activatesOn: ClientEvents.ClientReady,
|
|
22
|
-
activate: Blockstore
|
|
23
|
-
}), Plugin.addModule({
|
|
24
|
-
id: "instances",
|
|
25
|
-
activatesOn: ClientEvents.ClientReady,
|
|
26
|
-
activate: () => Effect.sync(() => {
|
|
27
|
-
const instances = {};
|
|
28
|
-
return Capability.contributes(WnfsCapabilities.Instances, instances);
|
|
29
|
-
})
|
|
30
|
-
}), Plugin.addModule({
|
|
31
|
-
id: "backend",
|
|
32
|
-
activatesOn: ClientEvents.ClientReady,
|
|
33
|
-
activate: Backend
|
|
34
|
-
}), Plugin.addModule({
|
|
35
|
-
id: "url-resolver",
|
|
36
|
-
activatesOn: ClientEvents.ClientReady,
|
|
37
|
-
activate: UrlResolver
|
|
38
|
-
}), AppPlugin.addPluginAssetModule({
|
|
39
|
-
asset: {
|
|
40
|
-
pluginId: meta.profile.key,
|
|
41
|
-
path: "PLUGIN.mdl",
|
|
42
|
-
content: PLUGIN_default,
|
|
43
|
-
mimeType: "application/x-mdl"
|
|
44
|
-
}
|
|
45
|
-
}), Plugin.make);
|
|
46
|
-
var WnfsPlugin_default = WnfsPlugin;
|
|
47
|
-
export {
|
|
48
|
-
WnfsPlugin,
|
|
49
|
-
WnfsPlugin_default as default
|
|
50
|
-
};
|
|
51
|
-
//# sourceMappingURL=WnfsPlugin.mjs.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/WnfsPlugin.tsx", "raw-loader:/__w/dxos/dxos/packages/plugins/plugin-wnfs/PLUGIN.mdl"],
|
|
4
|
-
"sourcesContent": ["//\n// Copyright 2024 DXOS.org\n//\n\nimport * as Effect from 'effect/Effect';\n\nimport { Capability, Plugin } from '@dxos/app-framework';\nimport { AppPlugin } from '@dxos/app-toolkit';\nimport { ClientEvents } from '@dxos/plugin-client';\n\nimport { Backend, Blockstore, UrlResolver } from '#capabilities';\nimport { meta } from '#meta';\nimport { translations } from '#translations';\nimport { WnfsCapabilities } from '#types';\n\n// eslint-disable-next-line import/no-relative-packages\nimport pluginSpec from '../PLUGIN.mdl?raw';\n\nexport const WnfsPlugin = Plugin.define(meta).pipe(\n AppPlugin.addTranslationsModule({ translations }),\n Plugin.addModule({\n id: 'blockstore',\n activatesOn: ClientEvents.ClientReady,\n activate: Blockstore,\n }),\n Plugin.addModule({\n id: 'instances',\n activatesOn: ClientEvents.ClientReady,\n activate: () =>\n Effect.sync(() => {\n const instances: WnfsCapabilities.Instances = {};\n return Capability.contributes(WnfsCapabilities.Instances, instances);\n }),\n }),\n Plugin.addModule({\n id: 'backend',\n activatesOn: ClientEvents.ClientReady,\n activate: Backend,\n }),\n Plugin.addModule({\n id: 'url-resolver',\n activatesOn: ClientEvents.ClientReady,\n activate: UrlResolver,\n }),\n AppPlugin.addPluginAssetModule({\n asset: { pluginId: meta.profile.key, path: 'PLUGIN.mdl', content: pluginSpec, mimeType: 'application/x-mdl' },\n }),\n Plugin.make,\n);\n\nexport default WnfsPlugin;\n", "---\nid: org.dxos.plugin.wnfs\nname: WnfsPlugin\nversion: 0.1.0\n---\n\nA decentralized file storage plugin for `DXOS` Composer built on the Web Native File System (WNFS) protocol.\n\nWNFS provides a content-addressed, end-to-end encrypted private file system using a cryptographic \"dark forest\"\nstructure. Files are stored as IPLD blocks referenced by CID, replicated to a DXOS edge blob service, and\ncached locally in IndexedDB. The plugin implements the `FileCapabilities.Backend` and `FileCapabilities.UrlResolver`\ninterfaces from `plugin-file`, so other plugins (e.g. `plugin-gallery`) can upload and resolve `wnfs://` URLs\nwithout knowing the underlying storage layer. Uploads are scoped to a DXOS Space; the private forest root CID\nis persisted on the space's ECHO properties and updated atomically on every write.\n\n## Extensions\n\nThe following extension dialects are used in this document.\nEach extension is defined in the Appendix or resolved via its URI.\n\n| Term | URI |\n|-------------|--------------------------------|\n| `type` | `org.dxos.mdl.type@1.0` |\n| `feat` | `org.dxos.mdl.feat@1.0` |\n| `test` | `org.dxos.mdl.test@1.0` |\n| `component` | `org.dxos.mdl.component@1.0` |\n| `op` | `org.dxos.mdl.op@1.0` |\n\n## Types\n\n```mdl\ntype Cid\n desc: A content-addressed identifier encoded as a base-32 multibase string (CIDv1, sha2-256).\n fields:\n value: string\n```\n\n```mdl\ntype WnfsPath\n desc: A WNFS path encoded as a wnfs:// URL; segments are percent-encoded.\n fields:\n url: string # wnfs://spaces/<spaceId>/files/<cid>\n```\n\n```mdl\ntype FileInfo\n desc: Metadata returned after a successful upload.\n fields:\n name: string # Original file name\n type: string # MIME type\n url: WnfsPath # wnfs:// URL for later retrieval\n cid: Cid # Raw content CID of the file bytes\n```\n\n```mdl\ntype BlockstoreState\n desc: Runtime state of the MixedBlockstore.\n fields:\n isConnected: boolean # Online / offline status\n queueLength: number # Blocks pending remote flush\n apiHost: string # DXOS edge service base URL\n```\n\n```mdl\ntype WnfsInstances\n desc: In-memory cache mapping forest CID \u2192 loaded PrivateDirectory/PrivateForest pair.\n fields:\n entries: Record<string, WnfsInstance>\n```\n\n```mdl\ntype WnfsInstance\n desc: A loaded WNFS private file system rooted at a specific forest CID.\n fields:\n directory: PrivateDirectory\n forest: PrivateForest\n```\n```\n\n## Operations\n\nPure operations \u2014 no external effects.\n\n```mdl\nop wnfsUrl\n desc: Encodes a WNFS path array into a wnfs:// URL with percent-encoded segments.\n input:\n filePath: string[]\n output: string\n```\n\n```mdl\nop getPathFromUrl\n desc: Decodes a wnfs:// URL back into a path segment array.\n input:\n wnfsUrl: string\n output: string[]\n```\n\nEffectful operations \u2014 read or write persistent state.\n\n```mdl\nop uploadFile\n desc: |\n Reads a File, writes its bytes into the WNFS private directory at a\n CID-derived path, persists the updated forest pointer to the space's\n ECHO properties, and returns FileInfo.\n input:\n file: File\n space: Space\n output: FileInfo\n effects: [fs:read, echo:write, http:post]\n errors:\n SpaceNotFound: no space matching the given spaceId\n BlockstoreError: local or remote block I/O failure\n note: |\n Uses sha256 for block hashing (not the rs-wnfs default Blake3) for\n broader runtime support. Writes are atomic from ECHO's perspective:\n the forest CID property is only updated after the forest is stored.\n```\n\n```mdl\nop resolveWnfsUrl\n desc: |\n Reads file bytes from the WNFS private directory for a wnfs:// URL,\n wraps them in a Blob, and returns an object URL for in-browser display.\n input:\n wnfsUrl: string\n space: Space\n type?: string # MIME type hint for Blob construction\n output: string # blob: object URL\n effects: [fs:read, http:get]\n errors:\n UrlNotResolvable: space not available or file not found in directory\n note: Errors are swallowed and undefined is returned to avoid breaking image renders.\n```\n\n```mdl\nop flushBlockQueue\n desc: |\n Drains the pending-upload queue by assembling a CAR archive and POSTing\n it to the edge blob service. Called automatically (debounced 5 s) on\n block writes and on the browser `online` event.\n input:\n blockstore: Blockstore\n output: void\n effects: [http:post]\n errors:\n NetworkError: remote POST failed\n note: Queue is persisted to IndexedDB so pending blocks survive page reloads.\n```\n\n## Features\n\n```mdl\nfeat F-1: Upload File\n\n req F-1.1:\n when: a FileCapabilities.Backend consumer calls upload(file, db)\n then: the file is read, hashed, written to the WNFS private directory,\n and stored in the blockstore\n\n req F-1.2:\n when: the write succeeds\n then: the space's ECHO properties are updated with the new privateForestCid\n\n req F-1.3:\n when: the upload completes\n then: a FileInfo object with name, type, size, and wnfs:// URL is returned to the caller\n```\n\n```mdl\nfeat F-2: Resolve wnfs:// URL\n\n req F-2.1:\n when: FileCapabilities.UrlResolver.test(url) is called with a wnfs:// URL\n then: returns true\n\n req F-2.2:\n when: FileCapabilities.UrlResolver.resolve(url, file, space) is called\n then: loads or reuses a cached WnfsInstance for the space and reads the file bytes\n\n req F-2.3:\n when: bytes are successfully read\n then: a blob: object URL is returned for in-browser display\n\n req F-2.4:\n when: the space is undefined or reading fails\n then: undefined is returned; no error is propagated\n```\n\n```mdl\nfeat F-3: Blockstore Sync\n\n req F-3.1:\n when: a block is written via blockstore.put\n then: the block is saved to IndexedDB and added to the upload queue\n\n req F-3.2:\n when: the browser is online and the queue is non-empty\n then: blocks are batched into a CAR archive and flushed to the edge service\n\n req F-3.3:\n when: the browser goes offline\n then: the queue is persisted to IndexedDB; no upload is attempted\n\n req F-3.4:\n when: the browser comes back online\n then: the flush is triggered automatically\n\n req F-3.5:\n when: a block is requested via blockstore.get and is present in IndexedDB\n then: the block is returned from the local cache without a network request\n\n req F-3.6:\n when: a block is not in IndexedDB and the browser is online\n then: the block is fetched from the edge service and cached locally\n```\n\n```mdl\nfeat F-4: Space-Scoped Private File System\n\n req F-4.1:\n when: a space has no existing WNFS properties\n then: a new PrivateForest and PrivateDirectory are created and stored\n\n req F-4.2:\n when: a space already has a privateForestCid\n then: the existing forest and directory are loaded from the blockstore\n\n req F-4.3:\n when: a WnfsInstance is already cached for the current forest CID\n then: the cached instance is reused without reloading from the blockstore\n\n req F-4.4:\n when: legacy snake_case WNFS properties are found on a space\n then: they are deleted and replaced with the current camelCase schema\n tags: [migration]\n```\n\n## Acceptance\n\n```mdl\ntest T-1: Upload creates FileInfo\n given: an active space and a registered WNFS backend\n when: a file is uploaded via the Backend capability\n then:\n - FileInfo.url starts with wnfs://\n - space.properties.wnfs.privateForestCid is set\n - block for the file is present in the local IndexedDB blockstore\n```\n\n```mdl\ntest T-2: Resolve uploaded file\n given: a file was previously uploaded and FileInfo.url is known\n when: UrlResolver.resolve(url, file, space) is called\n then:\n - a blob: object URL is returned\n - URL can be used as an img src\n```\n\n```mdl\ntest T-3: Resolve without space returns undefined\n given: a wnfs:// URL\n when: UrlResolver.resolve is called with space = undefined\n then: undefined is returned; no error is thrown\n```\n\n```mdl\ntest T-4: Blockstore offline queuing\n given: the browser is offline\n when: a block is written to the blockstore\n then:\n - block is saved to IndexedDB\n - queue length increases by 1\n - no HTTP request is made\n```\n\n```mdl\ntest T-5: Blockstore queue flushes on reconnect\n given: blocks are queued while offline\n when: the browser comes back online\n then:\n - a CAR archive containing all queued blocks is POSTed to the edge service\n - the queue is cleared\n```\n\n```mdl\ntest T-6: New space initializes WNFS directory\n given: a space with no WNFS properties\n when: loadWnfs is called for that space\n then:\n - a new PrivateForest and PrivateDirectory are created\n - space.properties.wnfs.privateForestCid is populated\n```\n\n```mdl\ntest T-7: WnfsInstance cache hit\n given: a WnfsInstance is cached for the current forest CID\n when: loadWnfs is called again for the same space\n then: the cached instance is returned; no blockstore reads occur\n```\n\n---\n\n## Appendix: Extension Definitions\n\nExtension block types used in this document are defined below using\nthe core `ext` primitive \u2014 the only construct the base language provides.\n\n```mdl\next type\n uri: org.dxos.mdl.type@1.0\n desc: A named data structure with typed fields and optional literals.\n fields:\n desc?: Prose\n fields?: FieldMap # name[?]: TypeExpr (# inline comment)\n literals?: UnionList # a | b | c\n extends?: TypeRef[]\n```\n\n```mdl\next feat\n uri: org.dxos.mdl.feat@1.0\n desc: A named feature grouping one or more requirements.\n fields:\n desc?: Prose\n req: RequirementList\n nesting: self # feat blocks may contain feat blocks\n```\n\n```mdl\next test\n uri: org.dxos.mdl.test@1.0\n desc: An acceptance scenario expressed as given / when / then steps.\n fields:\n given?: Step | Step[]\n when?: Step | Step[]\n then: Step | Step[]\n tags?: TagList\n```\n\n```mdl\next component\n uri: org.dxos.mdl.component@1.0\n desc: A UI component with props, internal state, slots, actions, and events.\n fields:\n desc?: Prose\n props?: FieldMap # external inputs (immutable inside component)\n state?: FieldMap # internal reactive state\n slots?: FieldMap # named ReactNode injection points\n actions?: ActionMap # methods the component exposes or handles\n emits?: EventMap # events the component raises to its parent\n layout?: CodeBlock # ASCII sketch of visual structure (non-normative)\n```\n\n```mdl\next op\n uri: org.dxos.mdl.op@1.0\n desc: |\n A named operation with typed inputs, outputs, and declared errors.\n Pure ops have no effects or requires. Effectful ops declare both.\n fields:\n desc?: Prose\n input?: FieldMap # named input parameters\n output?: TypeExpr # return type\n errors?: ErrorMap # name: Prose (when this error occurs)\n effects?: EffectList # echo:read | echo:write | http | fs | ...\n requires?: ServiceList # injected service dependencies\n note?: Prose # implementation guidance (non-normative)\n```\n"],
|
|
5
|
-
"mappings": ";;;AAIA,YAAYA,YAAY;AAExB,SAASC,YAAYC,cAAc;AACnC,SAASC,iBAAiB;AAC1B,SAASC,oBAAoB;AAE7B,SAASC,SAASC,YAAYC,mBAAmB;AACjD,SAASC,YAAY;AACrB,SAASC,oBAAoB;AAC7B,SAASC,wBAAwB;;;ACbjC;;;ADkBO,IAAMC,aAAaC,OAAOC,OAAOC,IAAAA,EAAMC,KAC5CC,UAAUC,sBAAsB;EAAEC;AAAa,CAAA,GAC/CN,OAAOO,UAAU;EACfC,IAAI;EACJC,aAAaC,aAAaC;EAC1BC,UAAUC;AACZ,CAAA,GACAb,OAAOO,UAAU;EACfC,IAAI;EACJC,aAAaC,aAAaC;EAC1BC,UAAU,MACDE,YAAK,MAAA;AACV,UAAMC,YAAwC,CAAC;AAC/C,WAAOC,WAAWC,YAAYC,iBAAiBC,WAAWJ,SAAAA;EAC5D,CAAA;AACJ,CAAA,GACAf,OAAOO,UAAU;EACfC,IAAI;EACJC,aAAaC,aAAaC;EAC1BC,UAAUQ;AACZ,CAAA,GACApB,OAAOO,UAAU;EACfC,IAAI;EACJC,aAAaC,aAAaC;EAC1BC,UAAUS;AACZ,CAAA,GACAjB,UAAUkB,qBAAqB;EAC7BC,OAAO;IAAEC,UAAUtB,KAAKuB,QAAQC;IAAKC,MAAM;IAAcC,SAASC;IAAYC,UAAU;EAAoB;AAC9G,CAAA,GACA9B,OAAO+B,IAAI;AAGb,IAAA,qBAAehC;",
|
|
6
|
-
"names": ["Effect", "Capability", "Plugin", "AppPlugin", "ClientEvents", "Backend", "Blockstore", "UrlResolver", "meta", "translations", "WnfsCapabilities", "WnfsPlugin", "Plugin", "define", "meta", "pipe", "AppPlugin", "addTranslationsModule", "translations", "addModule", "id", "activatesOn", "ClientEvents", "ClientReady", "activate", "Blockstore", "sync", "instances", "Capability", "contributes", "WnfsCapabilities", "Instances", "Backend", "UrlResolver", "addPluginAssetModule", "asset", "pluginId", "profile", "key", "path", "content", "pluginSpec", "mimeType", "make"]
|
|
7
|
-
}
|