@wordup-md/unstorage-drivers 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +176 -2
- package/dist/_utils-B_aavWpW.mjs +43 -0
- package/dist/_utils-fCOHgF0_.d.mts +13 -0
- package/dist/forgejo.d.mts +30 -0
- package/dist/forgejo.mjs +82 -0
- package/dist/git-fs.d.mts +41 -0
- package/dist/git-fs.mjs +121 -0
- package/dist/gitlab.d.mts +27 -0
- package/dist/gitlab.mjs +84 -0
- package/dist/ipfs.d.mts +21 -0
- package/dist/ipfs.mjs +133 -0
- package/dist/opfs.d.mts +20 -0
- package/dist/opfs.mjs +115 -0
- package/dist/overlay.d.mts +17 -0
- package/dist/overlay.mjs +75 -0
- package/package.json +78 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ManUtopiK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,177 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wordup-md/unstorage-drivers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Isomorphic [unstorage](https://unstorage.unjs.io) drivers, part of the WordUp ecosystem.
|
|
4
|
+
|
|
5
|
+
Every driver is its own subpath export: an app only loads the drivers it imports. Drivers rely on web
|
|
6
|
+
standards (`fetch`, `TextDecoder`, `URLSearchParams`, the File System API), never on Node modules.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @wordup-md/unstorage-drivers unstorage
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Drivers
|
|
15
|
+
|
|
16
|
+
| Driver | Import | Access | Runs in |
|
|
17
|
+
| --------- | -------------------------------------- | ---------- | --------------------- |
|
|
18
|
+
| `forgejo` | `@wordup-md/unstorage-drivers/forgejo` | Read-only | Anywhere with `fetch` |
|
|
19
|
+
| `gitlab` | `@wordup-md/unstorage-drivers/gitlab` | Read-only | Anywhere with `fetch` |
|
|
20
|
+
| `overlay` | `@wordup-md/unstorage-drivers/overlay` | Read-write | Anywhere |
|
|
21
|
+
| `opfs` | `@wordup-md/unstorage-drivers/opfs` | Read-write | Browser |
|
|
22
|
+
| `ipfs` | `@wordup-md/unstorage-drivers/ipfs` | Read-write | Anywhere with Helia |
|
|
23
|
+
| `git-fs` | `@wordup-md/unstorage-drivers/git-fs` | Read-write | Anywhere (adapter) |
|
|
24
|
+
|
|
25
|
+
### `forgejo` and `gitlab`
|
|
26
|
+
|
|
27
|
+
Map the files of a Forgejo (or Gitea) or GitLab repository, the counterparts of `unstorage/drivers/github`.
|
|
28
|
+
The git tree is listed once and kept for `ttl` seconds; file bodies are fetched on demand, pinned to `branch`.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createStorage } from "unstorage";
|
|
32
|
+
import forgejoDriver from "@wordup-md/unstorage-drivers/forgejo";
|
|
33
|
+
import gitlabDriver from "@wordup-md/unstorage-drivers/gitlab";
|
|
34
|
+
|
|
35
|
+
const docs = createStorage({
|
|
36
|
+
driver: forgejoDriver({ apiURL: "https://codeberg.org", repo: "owner/name", dir: "content" }),
|
|
37
|
+
});
|
|
38
|
+
const site = createStorage({
|
|
39
|
+
driver: gitlabDriver({
|
|
40
|
+
repo: "group/name",
|
|
41
|
+
getHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` }),
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await docs.getKeys(); // ["index.md", "docs:guide.md", ...]
|
|
46
|
+
await docs.getItem("index.md");
|
|
47
|
+
await docs.getKeys("", { force: true }); // refetch the tree now
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Option | Type | Default | Description |
|
|
51
|
+
| ------------ | ------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
52
|
+
| `repo` | `string` | required | `owner/name` of the repository (GitLab: project path or ID). |
|
|
53
|
+
| `branch` | `string` | Forgejo: `"main"`, GitLab: the project's default branch | Branch, tag or commit SHA to read. |
|
|
54
|
+
| `dir` | `string` | `""` | Directory of the repository used as the driver root. |
|
|
55
|
+
| `ttl` | `number` | `600` | Seconds the file list is reused. `Infinity` for a pinned commit. |
|
|
56
|
+
| `token` | `string` | | Access token, needed for private repositories. |
|
|
57
|
+
| `getHeaders` | `() => HeadersInit` | | Headers resolved before every request (OAuth bearer, refreshed token). |
|
|
58
|
+
| `apiURL` | `string` | Forgejo: `"https://codeberg.org"`, GitLab: `"https://gitlab.com"` | Base URL of the instance. |
|
|
59
|
+
|
|
60
|
+
`getMeta(key)` returns the blob `sha` and `mode` (and `size` on Forgejo).
|
|
61
|
+
|
|
62
|
+
### `overlay`
|
|
63
|
+
|
|
64
|
+
Stacks layers over a base, to edit a read-only source locally: `layers[0]` is the writable top layer,
|
|
65
|
+
the last layer is the base. The top layer only ever holds the changes: a value set back to the base one
|
|
66
|
+
is dropped, a removed base file leaves a marker.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import indexedDbDriver from "unstorage/drivers/indexedb";
|
|
70
|
+
import overlayDriver from "@wordup-md/unstorage-drivers/overlay";
|
|
71
|
+
|
|
72
|
+
const overlay = overlayDriver({
|
|
73
|
+
layers: [indexedDbDriver({ dbName: "changes" }), gitlabDriver({ repo: "group/name" })],
|
|
74
|
+
});
|
|
75
|
+
const storage = createStorage({ driver: overlay });
|
|
76
|
+
|
|
77
|
+
await storage.setItem("docs:intro.md", "# Intro", { previousPath: "docs/guide.md" }); // a rename
|
|
78
|
+
await storage.getItem("docs:intro.md", { original: true }); // the base value
|
|
79
|
+
// Once the changes are committed to the base:
|
|
80
|
+
await overlay.mergeItem("docs:intro.md");
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `opfs`
|
|
84
|
+
|
|
85
|
+
Files in the Origin Private File System of the browser, or in any `FileSystemDirectoryHandle`
|
|
86
|
+
(e.g. a folder picked with `showDirectoryPicker()`).
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
| -------- | --------------------------- | ---------------------------------- | ---------------------------------------------------- |
|
|
90
|
+
| `fs` | `FileSystemDirectoryHandle` | `navigator.storage.getDirectory()` | Directory used as the filesystem root. |
|
|
91
|
+
| `base` | `string` | `""` | Directory under the root used as the driver root. |
|
|
92
|
+
| `ignore` | `(path) => boolean` | | Skips the files and directories it returns true for. |
|
|
93
|
+
|
|
94
|
+
`getMeta(key)` returns the `mtime`, `size` and `type` of the file.
|
|
95
|
+
|
|
96
|
+
### `ipfs`
|
|
97
|
+
|
|
98
|
+
A UnixFS directory on IPFS, with [Helia](https://helia.io). The whole storage is one directory DAG,
|
|
99
|
+
so its state is a single root CID: every write builds a new root, from the changed file up, and hands it
|
|
100
|
+
to `onRootChange()` to persist or publish. Pass it back as `root` to reopen the same storage.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createHelia } from "helia";
|
|
104
|
+
import { unixfs } from "@helia/unixfs";
|
|
105
|
+
import { CID } from "multiformats/cid";
|
|
106
|
+
import ipfsDriver from "@wordup-md/unstorage-drivers/ipfs";
|
|
107
|
+
|
|
108
|
+
const helia = await createHelia();
|
|
109
|
+
const saved = localStorage.getItem("root");
|
|
110
|
+
const driver = ipfsDriver({
|
|
111
|
+
fs: unixfs(helia),
|
|
112
|
+
root: saved ? CID.parse(saved) : undefined,
|
|
113
|
+
onRootChange: (root) => localStorage.setItem("root", root.toString()),
|
|
114
|
+
});
|
|
115
|
+
const storage = createStorage({ driver });
|
|
116
|
+
|
|
117
|
+
await storage.setItem("docs:guide.md", "# Guide");
|
|
118
|
+
await driver.getRoot(); // the CID of the whole storage
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Option | Type | Default | Description |
|
|
122
|
+
| -------------- | --------------------- | --------------------- | ------------------------------------------- |
|
|
123
|
+
| `fs` | `UnixFS` | required | The UnixFS instance, e.g. `unixfs(helia)`. |
|
|
124
|
+
| `root` | `CID` | a new empty directory | CID of the directory to open. |
|
|
125
|
+
| `onRootChange` | `(root: CID) => void` | | Called with the new root after every write. |
|
|
126
|
+
|
|
127
|
+
Writes run one at a time; a failed write keeps the previous root. Removing a file prunes the
|
|
128
|
+
directories it leaves empty, so the same files always make the same root. `getMeta(key)` returns the
|
|
129
|
+
file `cid`, `size`, `mode` and `mtime`. `@helia/unixfs` and `multiformats` are optional peer
|
|
130
|
+
dependencies: the driver only imports their types.
|
|
131
|
+
|
|
132
|
+
### `git-fs`
|
|
133
|
+
|
|
134
|
+
Not a driver but the other way around: a filesystem over any unstorage instance, for
|
|
135
|
+
[isomorphic-git](https://isomorphic-git.org). Files are storage items (path `a/b.md` is key `a:b.md`),
|
|
136
|
+
directories are implicit, and reads and writes go raw so git objects stay byte-exact on any driver.
|
|
137
|
+
A file written with `storage.setItem()` is a file git sees.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import git from "isomorphic-git";
|
|
141
|
+
import http from "isomorphic-git/http/web";
|
|
142
|
+
import { createStorage, prefixStorage } from "unstorage";
|
|
143
|
+
import createGitFs from "@wordup-md/unstorage-drivers/git-fs";
|
|
144
|
+
import opfsDriver from "@wordup-md/unstorage-drivers/opfs";
|
|
145
|
+
|
|
146
|
+
const storage = createStorage({ driver: opfsDriver() });
|
|
147
|
+
const fs = createGitFs(prefixStorage(storage, "repos:site"));
|
|
148
|
+
|
|
149
|
+
await git.clone({
|
|
150
|
+
fs,
|
|
151
|
+
http,
|
|
152
|
+
dir: "/",
|
|
153
|
+
url: "https://example.com/site.git",
|
|
154
|
+
corsProxy: "https://cors.isomorphic-git.org",
|
|
155
|
+
});
|
|
156
|
+
await storage.setItem("repos:site:index.md", "# Edited");
|
|
157
|
+
await git.status({ fs, dir: "/", filepath: "index.md" }); // "*modified"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Without a native `mtime` in the driver meta, the file stats carry a hash of the content, so git only
|
|
161
|
+
rehashes the files that changed.
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
vp install # install dependencies
|
|
167
|
+
vp test # run the tests
|
|
168
|
+
vp check # format, lint and type check
|
|
169
|
+
vp pack # build the library
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A new driver goes in `src/<name>.ts` with an entry in the `pack` block of `vite.config.ts`;
|
|
173
|
+
the `package.json` exports are generated on build.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/_utils.ts
|
|
2
|
+
/**
|
|
3
|
+
* Helpers shared by the drivers. Bundled into each driver that imports them, never exported.
|
|
4
|
+
*/
|
|
5
|
+
function createError(driver, message, options) {
|
|
6
|
+
return new Error(`[unstorage] [${driver}] ${message}`, options);
|
|
7
|
+
}
|
|
8
|
+
/** The headers of a forge request: the `token` header, then the ones from `getHeaders()`. */
|
|
9
|
+
async function forgeHeaders(options, tokenHeader) {
|
|
10
|
+
const headers = new Headers(options.token ? tokenHeader(options.token) : void 0);
|
|
11
|
+
if (options.getHeaders) for (const [name, value] of new Headers(await options.getHeaders())) headers.set(name, value);
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A file listing fetched at most once per `ttl` seconds. Concurrent reads share one fetch,
|
|
16
|
+
* a failed fetch is dropped so the next read retries, and `force` refetches right away.
|
|
17
|
+
*/
|
|
18
|
+
function createListingCache(fetchListing, ttl) {
|
|
19
|
+
let files = {};
|
|
20
|
+
let lastCheck;
|
|
21
|
+
let pending;
|
|
22
|
+
return async function getFiles(force = false) {
|
|
23
|
+
if (!force && lastCheck !== void 0 && lastCheck + ttl * 1e3 > Date.now()) return files;
|
|
24
|
+
pending ??= fetchListing().finally(() => {
|
|
25
|
+
pending = void 0;
|
|
26
|
+
});
|
|
27
|
+
files = await pending;
|
|
28
|
+
lastCheck = Date.now();
|
|
29
|
+
return files;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Drop the options set to `undefined`, so they keep their default. */
|
|
33
|
+
function withDefaults(defaults, options) {
|
|
34
|
+
return {
|
|
35
|
+
...defaults,
|
|
36
|
+
...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== void 0))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function trimSlashes(path) {
|
|
40
|
+
return path.replace(/^\/+|\/+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { withDefaults as a, trimSlashes as i, createListingCache as n, forgeHeaders as r, createError as t };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/_utils.d.ts
|
|
2
|
+
/** Options common to the drivers reading a git forge through its REST API. */
|
|
3
|
+
interface ForgeAuthOptions {
|
|
4
|
+
/** Access token, needed for private repositories. */
|
|
5
|
+
token?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Request headers, resolved before every request: for an OAuth `Authorization: Bearer …` header,
|
|
8
|
+
* or a token that may be refreshed. Merged over the `token` header.
|
|
9
|
+
*/
|
|
10
|
+
getHeaders?: () => HeadersInit | Promise<HeadersInit>;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { ForgeAuthOptions as t };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { t as ForgeAuthOptions } from "./_utils-fCOHgF0_.mjs";
|
|
2
|
+
import { Driver } from "unstorage";
|
|
3
|
+
//#region src/forgejo.d.ts
|
|
4
|
+
export interface ForgejoDriverOptions extends ForgeAuthOptions {
|
|
5
|
+
/** `owner/name` of the repository. Required. */
|
|
6
|
+
repo: string;
|
|
7
|
+
/**
|
|
8
|
+
* Branch, tag or commit SHA to read.
|
|
9
|
+
* @default "main"
|
|
10
|
+
*/
|
|
11
|
+
branch?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Directory of the repository to use as the driver root.
|
|
14
|
+
* @default ""
|
|
15
|
+
*/
|
|
16
|
+
dir?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Seconds the file list is reused before the tree is fetched again.
|
|
19
|
+
* `getKeys(base, { force: true })` refetches it right away.
|
|
20
|
+
* @default 600
|
|
21
|
+
*/
|
|
22
|
+
ttl?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Base URL of the Forgejo instance.
|
|
25
|
+
* @default "https://codeberg.org"
|
|
26
|
+
*/
|
|
27
|
+
apiURL?: string;
|
|
28
|
+
}
|
|
29
|
+
export default function forgejoDriver(_opts: ForgejoDriverOptions): Driver;
|
|
30
|
+
//#endregion
|
package/dist/forgejo.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { a as withDefaults, i as trimSlashes, n as createListingCache, r as forgeHeaders, t as createError } from "./_utils-B_aavWpW.mjs";
|
|
2
|
+
//#region src/forgejo.ts
|
|
3
|
+
const DRIVER_NAME = "forgejo";
|
|
4
|
+
/** Forgejo caps the page size of a tree at 1000 entries by default. */
|
|
5
|
+
const TREE_PAGE_SIZE = 1e3;
|
|
6
|
+
const defaultOptions = {
|
|
7
|
+
branch: "main",
|
|
8
|
+
dir: "",
|
|
9
|
+
ttl: 600,
|
|
10
|
+
apiURL: "https://codeberg.org"
|
|
11
|
+
};
|
|
12
|
+
function forgejoDriver(_opts) {
|
|
13
|
+
const opts = withDefaults(defaultOptions, _opts);
|
|
14
|
+
if (!opts.repo) throw createError(DRIVER_NAME, "Missing required option `repo`.");
|
|
15
|
+
const dir = trimSlashes(opts.dir);
|
|
16
|
+
const prefix = dir ? `${dir}/` : "";
|
|
17
|
+
const getFiles = createListingCache(fetchFiles, opts.ttl);
|
|
18
|
+
async function fetchFiles() {
|
|
19
|
+
const found = {};
|
|
20
|
+
let seen = 0;
|
|
21
|
+
try {
|
|
22
|
+
for (let page = 1;; page++) {
|
|
23
|
+
const query = new URLSearchParams({
|
|
24
|
+
recursive: "true",
|
|
25
|
+
per_page: String(TREE_PAGE_SIZE),
|
|
26
|
+
page: String(page)
|
|
27
|
+
});
|
|
28
|
+
const { tree, truncated, total_count } = await (await request(`git/trees/${encodeURIComponent(opts.branch)}?${query.toString()}`)).json();
|
|
29
|
+
for (const node of tree ?? []) {
|
|
30
|
+
if (node.type !== "blob" || !node.path.startsWith(prefix)) continue;
|
|
31
|
+
const key = node.path.slice(prefix.length).replace(/\//g, ":");
|
|
32
|
+
found[key] = {
|
|
33
|
+
sha: node.sha,
|
|
34
|
+
mode: node.mode,
|
|
35
|
+
size: node.size
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
seen += tree?.length ?? 0;
|
|
39
|
+
if (!truncated || !tree?.length || total_count !== void 0 && seen >= total_count) return found;
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw createError(DRIVER_NAME, "Failed to fetch git tree", { cause: error });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function fetchRaw(key) {
|
|
46
|
+
if (!(key in await getFiles())) return null;
|
|
47
|
+
const path = `${prefix}${key.replace(/:/g, "/")}`.split("/").map(encodeURIComponent).join("/");
|
|
48
|
+
try {
|
|
49
|
+
const res = await request(`raw/${path}?ref=${encodeURIComponent(opts.branch)}`);
|
|
50
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw createError(DRIVER_NAME, `Failed to fetch \`${JSON.stringify(key)}\``, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function request(endpoint) {
|
|
56
|
+
const url = `${opts.apiURL.replace(/\/+$/, "")}/api/v1/repos/${opts.repo}/${endpoint}`;
|
|
57
|
+
const headers = await forgeHeaders(opts, (token) => ({ Authorization: `token ${token}` }));
|
|
58
|
+
const res = await fetch(url, { headers });
|
|
59
|
+
if (!res.ok) throw Object.assign(/* @__PURE__ */ new Error(`Forgejo ${res.status} on ${url}`), { statusCode: res.status });
|
|
60
|
+
return res;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
name: DRIVER_NAME,
|
|
64
|
+
options: opts,
|
|
65
|
+
async getKeys(_base, tOpts) {
|
|
66
|
+
return Object.keys(await getFiles(Boolean(tOpts?.force)));
|
|
67
|
+
},
|
|
68
|
+
async hasItem(key) {
|
|
69
|
+
return key in await getFiles();
|
|
70
|
+
},
|
|
71
|
+
async getItem(key) {
|
|
72
|
+
const body = await fetchRaw(key);
|
|
73
|
+
return body && new TextDecoder().decode(body);
|
|
74
|
+
},
|
|
75
|
+
getItemRaw: fetchRaw,
|
|
76
|
+
async getMeta(key) {
|
|
77
|
+
return (await getFiles())[key] ?? null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { forgejoDriver as default };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Storage } from "unstorage";
|
|
2
|
+
//#region src/git-fs.d.ts
|
|
3
|
+
type Encoding = "utf8" | "utf-8";
|
|
4
|
+
type EncodingOption = Encoding | {
|
|
5
|
+
encoding?: Encoding;
|
|
6
|
+
} | undefined;
|
|
7
|
+
export interface GitFsStats {
|
|
8
|
+
type: "file" | "dir";
|
|
9
|
+
mode: number;
|
|
10
|
+
size: number;
|
|
11
|
+
ino: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
ctimeMs: number;
|
|
14
|
+
uid: number;
|
|
15
|
+
gid: number;
|
|
16
|
+
dev: number;
|
|
17
|
+
isFile(): boolean;
|
|
18
|
+
isDirectory(): boolean;
|
|
19
|
+
isSymbolicLink(): boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The `PromiseFsClient` shape isomorphic-git expects, without depending on it. */
|
|
22
|
+
export interface GitFs {
|
|
23
|
+
promises: {
|
|
24
|
+
readFile(path: string, options?: EncodingOption): Promise<Uint8Array | string>;
|
|
25
|
+
writeFile(path: string, data: Uint8Array | string, options?: EncodingOption | {
|
|
26
|
+
mode?: number;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
unlink(path: string): Promise<void>;
|
|
29
|
+
readdir(path: string): Promise<string[]>;
|
|
30
|
+
mkdir(path: string, mode?: number): Promise<void>;
|
|
31
|
+
rmdir(path: string): Promise<void>;
|
|
32
|
+
stat(path: string): Promise<GitFsStats>;
|
|
33
|
+
lstat(path: string): Promise<GitFsStats>;
|
|
34
|
+
readlink(path: string): Promise<string>;
|
|
35
|
+
symlink(target: string, path: string): Promise<void>;
|
|
36
|
+
chmod(path: string, mode: number): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export default function createGitFs(storage: Storage): GitFs;
|
|
40
|
+
//#endregion
|
|
41
|
+
export {};
|
package/dist/git-fs.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { normalizeKey } from "unstorage";
|
|
2
|
+
//#region src/git-fs.ts
|
|
3
|
+
/**
|
|
4
|
+
* A filesystem over an unstorage instance, for isomorphic-git: `git.clone({ fs: createGitFs(storage), dir: "/" })`.
|
|
5
|
+
* Files are storage items (path `a/b.md` is key `a:b.md`), directories are implicit in the keys,
|
|
6
|
+
* and every read and write goes raw, so git objects stay byte-exact on any driver.
|
|
7
|
+
* Mount it on a sub-path with `prefixStorage(storage, "repo")`.
|
|
8
|
+
*
|
|
9
|
+
* Not a driver but the other way around: any driver (memory, IndexedDB, OPFS, overlay…) becomes
|
|
10
|
+
* a git working tree, and `storage.setItem()` writes a file git sees.
|
|
11
|
+
*/
|
|
12
|
+
const encoder = /* @__PURE__ */ new TextEncoder();
|
|
13
|
+
const decoder = /* @__PURE__ */ new TextDecoder();
|
|
14
|
+
function createGitFs(storage) {
|
|
15
|
+
/** The keys of every file under the directory `key` (the root when empty). */
|
|
16
|
+
const keysUnder = async (key) => key ? (await storage.getKeys(key)).filter((k) => k.startsWith(`${key}:`)) : storage.getKeys();
|
|
17
|
+
async function readBytes(path, syscall) {
|
|
18
|
+
const key = toKey(path);
|
|
19
|
+
const value = key ? await storage.getItemRaw(key) : null;
|
|
20
|
+
if (value === null || value === void 0) throw fsError("ENOENT", syscall, path);
|
|
21
|
+
return toBytes(value);
|
|
22
|
+
}
|
|
23
|
+
async function stat(path) {
|
|
24
|
+
const key = toKey(path);
|
|
25
|
+
if (key && await storage.hasItem(key)) {
|
|
26
|
+
const meta = await storage.getMeta(key);
|
|
27
|
+
if (meta.mtime instanceof Date && typeof meta.size === "number") return createStats("file", meta.size, meta.mtime.getTime());
|
|
28
|
+
const bytes = await readBytes(path, "stat");
|
|
29
|
+
return createStats("file", bytes.length, 0, fnv1a(bytes));
|
|
30
|
+
}
|
|
31
|
+
if (!key || (await keysUnder(key)).length) return createStats("dir", 0);
|
|
32
|
+
throw fsError("ENOENT", "stat", path);
|
|
33
|
+
}
|
|
34
|
+
return { promises: {
|
|
35
|
+
async readFile(path, options) {
|
|
36
|
+
const bytes = await readBytes(path, "open");
|
|
37
|
+
return encodingOf(options) ? decoder.decode(bytes) : bytes;
|
|
38
|
+
},
|
|
39
|
+
async writeFile(path, data) {
|
|
40
|
+
const key = toKey(path);
|
|
41
|
+
if (!key) throw fsError("EISDIR", "open", path);
|
|
42
|
+
await storage.setItemRaw(key, typeof data === "string" ? encoder.encode(data) : data);
|
|
43
|
+
},
|
|
44
|
+
async unlink(path) {
|
|
45
|
+
const key = toKey(path);
|
|
46
|
+
if (!key || !await storage.hasItem(key)) throw fsError("ENOENT", "unlink", path);
|
|
47
|
+
await storage.removeItem(key);
|
|
48
|
+
},
|
|
49
|
+
async readdir(path) {
|
|
50
|
+
const key = toKey(path);
|
|
51
|
+
if (key && await storage.hasItem(key)) throw fsError("ENOTDIR", "scandir", path);
|
|
52
|
+
const keys = await keysUnder(key);
|
|
53
|
+
if (key && !keys.length) throw fsError("ENOENT", "scandir", path);
|
|
54
|
+
const offset = key ? key.length + 1 : 0;
|
|
55
|
+
return [...new Set(keys.map((k) => k.slice(offset).split(":")[0]))].sort();
|
|
56
|
+
},
|
|
57
|
+
async mkdir(path) {
|
|
58
|
+
const key = toKey(path);
|
|
59
|
+
if (!key || await storage.hasItem(key) || (await keysUnder(key)).length) throw fsError("EEXIST", "mkdir", path);
|
|
60
|
+
},
|
|
61
|
+
async rmdir(path) {
|
|
62
|
+
const key = toKey(path);
|
|
63
|
+
if (key && await storage.hasItem(key)) throw fsError("ENOTDIR", "rmdir", path);
|
|
64
|
+
if ((await keysUnder(key)).length) throw fsError("ENOTEMPTY", "rmdir", path);
|
|
65
|
+
},
|
|
66
|
+
stat,
|
|
67
|
+
lstat: stat,
|
|
68
|
+
async readlink(path) {
|
|
69
|
+
throw fsError("ENOSYS", "readlink", path);
|
|
70
|
+
},
|
|
71
|
+
async symlink(_target, path) {
|
|
72
|
+
throw fsError("ENOSYS", "symlink", path);
|
|
73
|
+
},
|
|
74
|
+
async chmod() {}
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
/** The storage key of a path: `/a/./b.md` is `a:b.md`, the root is `""`. */
|
|
78
|
+
function toKey(path) {
|
|
79
|
+
const segments = path.split(/[/\\]+/).filter((segment) => segment && segment !== ".");
|
|
80
|
+
if (segments.includes("..")) throw fsError("EINVAL", "open", path);
|
|
81
|
+
return normalizeKey(segments.join(":"));
|
|
82
|
+
}
|
|
83
|
+
function toBytes(value) {
|
|
84
|
+
if (value instanceof Uint8Array) return value;
|
|
85
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
86
|
+
return encoder.encode(typeof value === "string" ? value : JSON.stringify(value));
|
|
87
|
+
}
|
|
88
|
+
function encodingOf(options) {
|
|
89
|
+
return typeof options === "string" ? options : options?.encoding;
|
|
90
|
+
}
|
|
91
|
+
function createStats(type, size, mtimeMs = 0, ino = 0) {
|
|
92
|
+
return {
|
|
93
|
+
type,
|
|
94
|
+
mode: type === "dir" ? 16877 : 33188,
|
|
95
|
+
size,
|
|
96
|
+
ino,
|
|
97
|
+
mtimeMs,
|
|
98
|
+
ctimeMs: mtimeMs,
|
|
99
|
+
uid: 1,
|
|
100
|
+
gid: 1,
|
|
101
|
+
dev: 1,
|
|
102
|
+
isFile: () => type === "file",
|
|
103
|
+
isDirectory: () => type === "dir",
|
|
104
|
+
isSymbolicLink: () => false
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** 32-bit FNV-1a hash. */
|
|
108
|
+
function fnv1a(bytes) {
|
|
109
|
+
let hash = 2166136261;
|
|
110
|
+
for (const byte of bytes) hash = Math.imul(hash ^ byte, 16777619);
|
|
111
|
+
return hash >>> 0;
|
|
112
|
+
}
|
|
113
|
+
function fsError(code, syscall, path) {
|
|
114
|
+
return Object.assign(/* @__PURE__ */ new Error(`${code}: ${syscall} '${path}'`), {
|
|
115
|
+
code,
|
|
116
|
+
syscall,
|
|
117
|
+
path
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
export { createGitFs as default };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { t as ForgeAuthOptions } from "./_utils-fCOHgF0_.mjs";
|
|
2
|
+
import { Driver } from "unstorage";
|
|
3
|
+
//#region src/gitlab.d.ts
|
|
4
|
+
export interface GitlabDriverOptions extends ForgeAuthOptions {
|
|
5
|
+
/** Path (`group/name`) or numeric ID of the project. Required. */
|
|
6
|
+
repo: string;
|
|
7
|
+
/** Branch, tag or commit SHA to read. Defaults to the default branch of the project. */
|
|
8
|
+
branch?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Directory of the repository to use as the driver root.
|
|
11
|
+
* @default ""
|
|
12
|
+
*/
|
|
13
|
+
dir?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Seconds the file list is reused before the tree is fetched again.
|
|
16
|
+
* `getKeys(base, { force: true })` refetches it right away.
|
|
17
|
+
* @default 600
|
|
18
|
+
*/
|
|
19
|
+
ttl?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Base URL of the GitLab instance.
|
|
22
|
+
* @default "https://gitlab.com"
|
|
23
|
+
*/
|
|
24
|
+
apiURL?: string;
|
|
25
|
+
}
|
|
26
|
+
export default function gitlabDriver(_opts: GitlabDriverOptions): Driver;
|
|
27
|
+
//#endregion
|
package/dist/gitlab.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { a as withDefaults, i as trimSlashes, n as createListingCache, r as forgeHeaders, t as createError } from "./_utils-B_aavWpW.mjs";
|
|
2
|
+
//#region src/gitlab.ts
|
|
3
|
+
const DRIVER_NAME = "gitlab";
|
|
4
|
+
/** The largest page size GitLab accepts. */
|
|
5
|
+
const TREE_PAGE_SIZE = 100;
|
|
6
|
+
const defaultOptions = {
|
|
7
|
+
branch: "",
|
|
8
|
+
dir: "",
|
|
9
|
+
ttl: 600,
|
|
10
|
+
apiURL: "https://gitlab.com"
|
|
11
|
+
};
|
|
12
|
+
function gitlabDriver(_opts) {
|
|
13
|
+
const opts = withDefaults(defaultOptions, _opts);
|
|
14
|
+
if (!opts.repo) throw createError(DRIVER_NAME, "Missing required option `repo`.");
|
|
15
|
+
const dir = trimSlashes(opts.dir);
|
|
16
|
+
const prefix = dir ? `${dir}/` : "";
|
|
17
|
+
const getFiles = createListingCache(fetchFiles, opts.ttl);
|
|
18
|
+
async function fetchFiles() {
|
|
19
|
+
const found = {};
|
|
20
|
+
try {
|
|
21
|
+
for (let page = "1"; page;) {
|
|
22
|
+
const query = new URLSearchParams({
|
|
23
|
+
recursive: "true",
|
|
24
|
+
per_page: String(TREE_PAGE_SIZE),
|
|
25
|
+
page
|
|
26
|
+
});
|
|
27
|
+
if (opts.branch) query.set("ref", opts.branch);
|
|
28
|
+
if (dir) query.set("path", dir);
|
|
29
|
+
const res = await request(`repository/tree?${query.toString()}`);
|
|
30
|
+
for (const node of await res.json()) {
|
|
31
|
+
if (node.type !== "blob" || !node.path.startsWith(prefix)) continue;
|
|
32
|
+
const key = node.path.slice(prefix.length).replace(/\//g, ":");
|
|
33
|
+
found[key] = {
|
|
34
|
+
sha: node.id,
|
|
35
|
+
mode: node.mode
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
page = res.headers.get("x-next-page");
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw createError(DRIVER_NAME, "Failed to fetch git tree", { cause: error });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function fetchRaw(key) {
|
|
46
|
+
if (!(key in await getFiles())) return null;
|
|
47
|
+
const path = encodeURIComponent(`${prefix}${key.replace(/:/g, "/")}`);
|
|
48
|
+
const query = opts.branch ? `?ref=${encodeURIComponent(opts.branch)}` : "";
|
|
49
|
+
try {
|
|
50
|
+
const res = await request(`repository/files/${path}/raw${query}`);
|
|
51
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw createError(DRIVER_NAME, `Failed to fetch \`${JSON.stringify(key)}\``, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function request(endpoint) {
|
|
57
|
+
const project = encodeURIComponent(opts.repo);
|
|
58
|
+
const url = `${opts.apiURL.replace(/\/+$/, "")}/api/v4/projects/${project}/${endpoint}`;
|
|
59
|
+
const headers = await forgeHeaders(opts, (token) => ({ "PRIVATE-TOKEN": token }));
|
|
60
|
+
const res = await fetch(url, { headers });
|
|
61
|
+
if (!res.ok) throw Object.assign(/* @__PURE__ */ new Error(`GitLab ${res.status} on ${url}`), { statusCode: res.status });
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name: DRIVER_NAME,
|
|
66
|
+
options: opts,
|
|
67
|
+
async getKeys(_base, tOpts) {
|
|
68
|
+
return Object.keys(await getFiles(Boolean(tOpts?.force)));
|
|
69
|
+
},
|
|
70
|
+
async hasItem(key) {
|
|
71
|
+
return key in await getFiles();
|
|
72
|
+
},
|
|
73
|
+
async getItem(key) {
|
|
74
|
+
const body = await fetchRaw(key);
|
|
75
|
+
return body && new TextDecoder().decode(body);
|
|
76
|
+
},
|
|
77
|
+
getItemRaw: fetchRaw,
|
|
78
|
+
async getMeta(key) {
|
|
79
|
+
return (await getFiles())[key] ?? null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { gitlabDriver as default };
|
package/dist/ipfs.d.mts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Driver } from "unstorage";
|
|
2
|
+
import { UnixFS } from "@helia/unixfs";
|
|
3
|
+
import { CID } from "multiformats/cid";
|
|
4
|
+
//#region src/ipfs.d.ts
|
|
5
|
+
export interface IpfsDriverOptions {
|
|
6
|
+
/** The UnixFS instance to read and write with, e.g. `unixfs(helia)`. Required. */
|
|
7
|
+
fs: UnixFS;
|
|
8
|
+
/**
|
|
9
|
+
* CID of the directory to open.
|
|
10
|
+
* @default a new empty directory
|
|
11
|
+
*/
|
|
12
|
+
root?: CID;
|
|
13
|
+
/** Called with the new root CID after every write, in order. */
|
|
14
|
+
onRootChange?: (root: CID) => void | Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface IpfsDriver extends Driver {
|
|
17
|
+
/** The current root CID, once the pending writes are done. */
|
|
18
|
+
getRoot(): Promise<CID>;
|
|
19
|
+
}
|
|
20
|
+
export default function ipfsDriver(opts: IpfsDriverOptions): IpfsDriver;
|
|
21
|
+
//#endregion
|
package/dist/ipfs.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { t as createError } from "./_utils-B_aavWpW.mjs";
|
|
2
|
+
//#region src/ipfs.ts
|
|
3
|
+
const DRIVER_NAME = "ipfs";
|
|
4
|
+
/** Multicodec of a raw block, always a file. */
|
|
5
|
+
const RAW_CODEC = 85;
|
|
6
|
+
const encoder = /* @__PURE__ */ new TextEncoder();
|
|
7
|
+
function ipfsDriver(opts) {
|
|
8
|
+
const { fs } = opts;
|
|
9
|
+
if (!fs) throw createError(DRIVER_NAME, "Missing required option `fs`.");
|
|
10
|
+
let rootPromise = opts.root ? Promise.resolve(opts.root) : fs.addDirectory();
|
|
11
|
+
/** Apply a change to the root, one at a time; a failed change leaves the root as it was. */
|
|
12
|
+
async function update(change) {
|
|
13
|
+
const previous = rootPromise;
|
|
14
|
+
const next = previous.then(change);
|
|
15
|
+
rootPromise = next.catch(() => previous);
|
|
16
|
+
const root = await next;
|
|
17
|
+
if (!root.equals(await previous)) await opts.onRootChange?.(root);
|
|
18
|
+
}
|
|
19
|
+
async function statOf(root, path) {
|
|
20
|
+
return fs.stat(root, { path }).catch(ignoreNotFound);
|
|
21
|
+
}
|
|
22
|
+
async function readFile(key) {
|
|
23
|
+
const root = await rootPromise;
|
|
24
|
+
const path = toPath(key);
|
|
25
|
+
const stats = await statOf(root, path);
|
|
26
|
+
if (!stats || stats.type === "directory") return null;
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of fs.cat(root, { path })) chunks.push(chunk);
|
|
29
|
+
return concat(chunks);
|
|
30
|
+
}
|
|
31
|
+
/** The directory `dir` with `entry` at `segments` (or removed, when `entry` is null). */
|
|
32
|
+
async function setAt(dir, segments, entry) {
|
|
33
|
+
const [name, ...rest] = segments;
|
|
34
|
+
const child = await statOf(dir, name);
|
|
35
|
+
if (!rest.length) {
|
|
36
|
+
if (entry) return fs.cp(entry, dir, name, { force: true });
|
|
37
|
+
return child ? fs.rm(dir, name) : dir;
|
|
38
|
+
}
|
|
39
|
+
if (child && child.type !== "directory") throw createError(DRIVER_NAME, `\`${name}\` is a file, not a directory.`);
|
|
40
|
+
if (!child && !entry) return dir;
|
|
41
|
+
const updated = await setAt(child?.cid ?? await fs.addDirectory(), rest, entry);
|
|
42
|
+
if (!entry && await isEmpty(updated)) return fs.rm(dir, name);
|
|
43
|
+
return fs.cp(updated, dir, name, { force: true });
|
|
44
|
+
}
|
|
45
|
+
async function isEmpty(dir) {
|
|
46
|
+
for await (const _entry of fs.ls(dir)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
async function listFiles(dir, prefix) {
|
|
50
|
+
const keys = [];
|
|
51
|
+
for await (const entry of fs.ls(dir)) {
|
|
52
|
+
const key = prefix ? `${prefix}:${entry.name}` : entry.name;
|
|
53
|
+
if (await isDirectory(entry.cid)) keys.push(...await listFiles(entry.cid, key));
|
|
54
|
+
else keys.push(key);
|
|
55
|
+
}
|
|
56
|
+
return keys;
|
|
57
|
+
}
|
|
58
|
+
/** Entries don't tell their type: a raw block is a file, a dag-pb node needs a stat. */
|
|
59
|
+
async function isDirectory(cid) {
|
|
60
|
+
return cid.code !== RAW_CODEC && (await fs.stat(cid)).type === "directory";
|
|
61
|
+
}
|
|
62
|
+
const write = (key, bytes) => update(async (root) => setAt(root, toSegments(key), await fs.addBytes(bytes)));
|
|
63
|
+
return {
|
|
64
|
+
name: DRIVER_NAME,
|
|
65
|
+
options: opts,
|
|
66
|
+
getRoot: () => rootPromise,
|
|
67
|
+
async hasItem(key) {
|
|
68
|
+
const stats = await statOf(await rootPromise, toPath(key));
|
|
69
|
+
return !!stats && stats.type !== "directory";
|
|
70
|
+
},
|
|
71
|
+
async getItem(key) {
|
|
72
|
+
const bytes = await readFile(key);
|
|
73
|
+
return bytes && new TextDecoder().decode(bytes);
|
|
74
|
+
},
|
|
75
|
+
getItemRaw: readFile,
|
|
76
|
+
async getMeta(key) {
|
|
77
|
+
const stats = await statOf(await rootPromise, toPath(key));
|
|
78
|
+
if (!stats || stats.type === "directory") return null;
|
|
79
|
+
return {
|
|
80
|
+
cid: stats.cid.toString(),
|
|
81
|
+
size: Number(stats.size),
|
|
82
|
+
mode: stats.mode,
|
|
83
|
+
...stats.mtime && { mtime: /* @__PURE__ */ new Date(Number(stats.mtime.secs) * 1e3) }
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
async setItem(key, value) {
|
|
87
|
+
await write(key, encoder.encode(value));
|
|
88
|
+
},
|
|
89
|
+
async setItemRaw(key, value) {
|
|
90
|
+
await write(key, toBytes(value));
|
|
91
|
+
},
|
|
92
|
+
async removeItem(key) {
|
|
93
|
+
await update((root) => setAt(root, toSegments(key), null));
|
|
94
|
+
},
|
|
95
|
+
async getKeys() {
|
|
96
|
+
return listFiles(await rootPromise, "");
|
|
97
|
+
},
|
|
98
|
+
async clear(prefix) {
|
|
99
|
+
await update((root) => prefix ? setAt(root, toSegments(prefix), null) : fs.addDirectory());
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function toSegments(key) {
|
|
104
|
+
const segments = key.split(/[:/]/).filter(Boolean);
|
|
105
|
+
if (!segments.length || segments.some((segment) => segment === "." || segment === "..")) throw createError(DRIVER_NAME, `Invalid key: ${JSON.stringify(key)}.`);
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
function toPath(key) {
|
|
109
|
+
return toSegments(key).join("/");
|
|
110
|
+
}
|
|
111
|
+
function toBytes(value) {
|
|
112
|
+
if (value instanceof Uint8Array) return value;
|
|
113
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
114
|
+
return encoder.encode(typeof value === "string" ? value : JSON.stringify(value));
|
|
115
|
+
}
|
|
116
|
+
function concat(chunks) {
|
|
117
|
+
if (chunks.length === 1) return chunks[0];
|
|
118
|
+
const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0));
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const chunk of chunks) {
|
|
121
|
+
bytes.set(chunk, offset);
|
|
122
|
+
offset += chunk.length;
|
|
123
|
+
}
|
|
124
|
+
return bytes;
|
|
125
|
+
}
|
|
126
|
+
/** Resolve a missing path to nothing; any other failure stays an error. */
|
|
127
|
+
function ignoreNotFound(error) {
|
|
128
|
+
const { name, code } = error ?? {};
|
|
129
|
+
if (name === "DoesNotExistError" || name === "NotFoundError" || code === "ERR_NOT_FOUND") return null;
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
export { ipfsDriver as default };
|
package/dist/opfs.d.mts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Driver } from "unstorage";
|
|
2
|
+
//#region src/opfs.d.ts
|
|
3
|
+
export interface OpfsDriverOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The directory to use as the filesystem root.
|
|
6
|
+
* @default navigator.storage.getDirectory()
|
|
7
|
+
*/
|
|
8
|
+
fs?: FileSystemDirectoryHandle | Promise<FileSystemDirectoryHandle>;
|
|
9
|
+
/**
|
|
10
|
+
* Directory under the root to use as the driver root.
|
|
11
|
+
* @default ""
|
|
12
|
+
*/
|
|
13
|
+
base?: string;
|
|
14
|
+
/** Skip the files and directories whose path (relative to `base`) it returns true for. */
|
|
15
|
+
ignore?: (path: string) => boolean;
|
|
16
|
+
}
|
|
17
|
+
export default function opfsDriver(opts?: OpfsDriverOptions): Driver;
|
|
18
|
+
/** Collapse `/`, `:` and `.` segments, refusing `..` ones. */
|
|
19
|
+
export declare function normalizePath(path: string): string;
|
|
20
|
+
//#endregion
|
package/dist/opfs.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { t as createError } from "./_utils-B_aavWpW.mjs";
|
|
2
|
+
//#region src/opfs.ts
|
|
3
|
+
const DRIVER_NAME = "opfs";
|
|
4
|
+
let defaultRoot;
|
|
5
|
+
function getDefaultRoot() {
|
|
6
|
+
if (typeof globalThis.navigator?.storage?.getDirectory !== "function") throw createError(DRIVER_NAME, "No `fs` given and the Origin Private File System is unavailable.");
|
|
7
|
+
return defaultRoot ??= navigator.storage.getDirectory();
|
|
8
|
+
}
|
|
9
|
+
function opfsDriver(opts = {}) {
|
|
10
|
+
const base = normalizePath(opts.base ?? "");
|
|
11
|
+
const getRoot = async () => await opts.fs ?? getDefaultRoot();
|
|
12
|
+
const resolve = (key) => normalizePath(`${base}/${key}`);
|
|
13
|
+
async function readFile(key) {
|
|
14
|
+
const handle = await getFileHandle(await getRoot(), resolve(key));
|
|
15
|
+
return handle ? handle.getFile() : null;
|
|
16
|
+
}
|
|
17
|
+
async function writeFile(key, data) {
|
|
18
|
+
const path = resolve(key);
|
|
19
|
+
const writable = await (await (await getDirectoryHandle(await getRoot(), dirname(path), true)).getFileHandle(basename(path), { create: true })).createWritable();
|
|
20
|
+
await writable.write(data);
|
|
21
|
+
await writable.close();
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
name: DRIVER_NAME,
|
|
25
|
+
options: opts,
|
|
26
|
+
async hasItem(key) {
|
|
27
|
+
return await getFileHandle(await getRoot(), resolve(key)) !== null;
|
|
28
|
+
},
|
|
29
|
+
async getItem(key) {
|
|
30
|
+
const file = await readFile(key);
|
|
31
|
+
return file && file.text();
|
|
32
|
+
},
|
|
33
|
+
async getItemRaw(key) {
|
|
34
|
+
const file = await readFile(key);
|
|
35
|
+
return file && new Uint8Array(await file.arrayBuffer());
|
|
36
|
+
},
|
|
37
|
+
async getMeta(key) {
|
|
38
|
+
const file = await readFile(key);
|
|
39
|
+
return file && {
|
|
40
|
+
mtime: new Date(file.lastModified),
|
|
41
|
+
size: file.size,
|
|
42
|
+
type: file.type
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
async setItem(key, value) {
|
|
46
|
+
await writeFile(key, value);
|
|
47
|
+
},
|
|
48
|
+
async setItemRaw(key, value) {
|
|
49
|
+
await writeFile(key, value);
|
|
50
|
+
},
|
|
51
|
+
async removeItem(key) {
|
|
52
|
+
const path = resolve(key);
|
|
53
|
+
if (!path) throw createError(DRIVER_NAME, "Cannot remove the root directory.");
|
|
54
|
+
await (await getDirectoryHandle(await getRoot(), dirname(path)))?.removeEntry(basename(path)).catch(ignoreNotFound);
|
|
55
|
+
},
|
|
56
|
+
async getKeys() {
|
|
57
|
+
const dir = await getDirectoryHandle(await getRoot(), base);
|
|
58
|
+
return dir ? listFiles(dir, "", opts.ignore) : [];
|
|
59
|
+
},
|
|
60
|
+
async clear(prefix) {
|
|
61
|
+
const path = resolve(prefix ?? "");
|
|
62
|
+
const dir = await getDirectoryHandle(await getRoot(), path);
|
|
63
|
+
if (!dir) return;
|
|
64
|
+
const names = [];
|
|
65
|
+
for await (const name of dir.keys()) names.push(name);
|
|
66
|
+
await Promise.all(names.map((name) => dir.removeEntry(name, { recursive: true })));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Collapse `/`, `:` and `.` segments, refusing `..` ones. */
|
|
71
|
+
function normalizePath(path) {
|
|
72
|
+
const wrapped = `/${path}/`.replace(/[/:]+(\.[/:]+)*[/:]*/g, "/");
|
|
73
|
+
if (wrapped.includes("/../")) throw createError(DRIVER_NAME, `Invalid key: ${JSON.stringify(path)}. It must not contain .. segments`);
|
|
74
|
+
return wrapped.slice(1, -1);
|
|
75
|
+
}
|
|
76
|
+
function dirname(path) {
|
|
77
|
+
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
78
|
+
}
|
|
79
|
+
function basename(path) {
|
|
80
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
81
|
+
}
|
|
82
|
+
/** The directory at `path`, or nothing when it doesn't exist and `create` is not set. */
|
|
83
|
+
async function getDirectoryHandle(root, path, create = false) {
|
|
84
|
+
let dir = root;
|
|
85
|
+
for (const segment of path ? path.split("/") : []) {
|
|
86
|
+
const child = await dir.getDirectoryHandle(segment, { create }).catch(ignoreNotFound);
|
|
87
|
+
if (!child) return null;
|
|
88
|
+
dir = child;
|
|
89
|
+
}
|
|
90
|
+
return dir;
|
|
91
|
+
}
|
|
92
|
+
/** The file at `path`, or nothing when it doesn't exist or is a directory. */
|
|
93
|
+
async function getFileHandle(root, path) {
|
|
94
|
+
if (!path) return null;
|
|
95
|
+
const dir = await getDirectoryHandle(root, dirname(path));
|
|
96
|
+
return dir ? dir.getFileHandle(basename(path)).catch(ignoreNotFound) : null;
|
|
97
|
+
}
|
|
98
|
+
async function listFiles(dir, path, ignore) {
|
|
99
|
+
const files = [];
|
|
100
|
+
for await (const [name, handle] of dir.entries()) {
|
|
101
|
+
const entryPath = path ? `${path}/${name}` : name;
|
|
102
|
+
if (ignore?.(entryPath)) continue;
|
|
103
|
+
if (handle.kind === "file") files.push(entryPath);
|
|
104
|
+
else files.push(...await listFiles(handle, entryPath, ignore));
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
/** Resolve a missing entry to nothing; any other failure stays an error. */
|
|
109
|
+
function ignoreNotFound(error) {
|
|
110
|
+
const name = error?.name;
|
|
111
|
+
if (name === "NotFoundError" || name === "TypeMismatchError") return null;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { opfsDriver as default, normalizePath };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Driver } from "unstorage";
|
|
2
|
+
//#region src/overlay.d.ts
|
|
3
|
+
export interface OverlayDriverOptions {
|
|
4
|
+
/** The writable top layer first, the base last. */
|
|
5
|
+
layers: Driver[];
|
|
6
|
+
}
|
|
7
|
+
export interface OverlayDriver extends Driver {
|
|
8
|
+
/** Drop the change of `key` from the top layer, once it is committed to the base. */
|
|
9
|
+
mergeItem(key: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Hook for once changes are committed to the base. The file list of the base is not refetched:
|
|
12
|
+
* it refreshes on the `ttl` of the base driver.
|
|
13
|
+
*/
|
|
14
|
+
merge(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export default function overlayDriver(options: OverlayDriverOptions): OverlayDriver;
|
|
17
|
+
//#endregion
|
package/dist/overlay.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { normalizeKey } from "unstorage";
|
|
2
|
+
//#region src/overlay.ts
|
|
3
|
+
/**
|
|
4
|
+
* Unstorage driver stacking layers over a base, to edit a read-only source locally.
|
|
5
|
+
* `layers[0]` is the writable top layer (e.g. IndexedDB) and the last layer is the base
|
|
6
|
+
* (e.g. a git forge). Reads go top-down. Writes only touch the top layer, which then holds
|
|
7
|
+
* nothing but the changes: a value set back to the base one is dropped, a removed base file
|
|
8
|
+
* leaves a marker. Once the changes are committed to the base, `mergeItem()` drops them.
|
|
9
|
+
*
|
|
10
|
+
* A superset of `unstorage/drivers/overlay`, which neither compares with the base nor merges.
|
|
11
|
+
*/
|
|
12
|
+
/** Same marker as `unstorage/drivers/overlay`. */
|
|
13
|
+
const OVERLAY_REMOVED = "__OVERLAY_REMOVED__";
|
|
14
|
+
/** Suffix of the key holding the meta of an item, as unstorage's `setMeta()` does. */
|
|
15
|
+
const META_SUFFIX = "$";
|
|
16
|
+
const DRIVER_NAME = "overlay";
|
|
17
|
+
function overlayDriver(options) {
|
|
18
|
+
const { layers } = options;
|
|
19
|
+
const top = layers[0];
|
|
20
|
+
const base = layers.at(-1);
|
|
21
|
+
const isRemoved = async (key) => await top?.getItem(key) === OVERLAY_REMOVED;
|
|
22
|
+
async function read(opts, get) {
|
|
23
|
+
if (opts?.original) return base ? await get(base) ?? null : null;
|
|
24
|
+
for (const layer of layers) {
|
|
25
|
+
const value = await get(layer);
|
|
26
|
+
if (value === OVERLAY_REMOVED) return null;
|
|
27
|
+
if (value !== null && value !== void 0) return value;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
async function dropChange(key, opts = {}) {
|
|
32
|
+
await top?.removeItem?.(key, opts);
|
|
33
|
+
await top?.removeItem?.(key + META_SUFFIX, opts);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: DRIVER_NAME,
|
|
37
|
+
options,
|
|
38
|
+
async hasItem(key, opts) {
|
|
39
|
+
for (const layer of layers) if (await layer.hasItem(key, opts)) return layer !== top || !await isRemoved(key);
|
|
40
|
+
return false;
|
|
41
|
+
},
|
|
42
|
+
async getItem(key, opts) {
|
|
43
|
+
return await read(opts, (layer) => layer.getItem(key, opts));
|
|
44
|
+
},
|
|
45
|
+
async getItemRaw(key, opts) {
|
|
46
|
+
return read(opts, (layer) => layer.getItemRaw ? layer.getItemRaw(key, opts) : layer.getItem(key, opts));
|
|
47
|
+
},
|
|
48
|
+
async setItem(key, value, opts) {
|
|
49
|
+
if (value === await base?.getItem(key, opts)) return dropChange(key, opts);
|
|
50
|
+
await top?.setItem?.(key, value, opts);
|
|
51
|
+
if (opts?.previousPath) {
|
|
52
|
+
const previousPath = String(opts.previousPath).replaceAll("/", ":");
|
|
53
|
+
await top?.setItem?.(key + META_SUFFIX, JSON.stringify({ previousPath }), opts);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async removeItem(key, opts) {
|
|
57
|
+
if (opts?.force || !await base?.hasItem(key, opts)) return dropChange(key, opts);
|
|
58
|
+
await top?.setItem?.(key, OVERLAY_REMOVED, opts);
|
|
59
|
+
},
|
|
60
|
+
async getKeys(prefix, opts) {
|
|
61
|
+
const keysByLayer = await Promise.all(layers.map(async (layer) => (await layer.getKeys(prefix, opts)).map(normalizeKey)));
|
|
62
|
+
const removed = new Set((await Promise.all((keysByLayer[0] ?? []).map(async (key) => await isRemoved(key) ? key : null))).filter((key) => key !== null));
|
|
63
|
+
return [...new Set(keysByLayer.flat())].filter((key) => !removed.has(key));
|
|
64
|
+
},
|
|
65
|
+
async mergeItem(key) {
|
|
66
|
+
await dropChange(key);
|
|
67
|
+
},
|
|
68
|
+
async merge() {},
|
|
69
|
+
async dispose() {
|
|
70
|
+
await Promise.all(layers.map(async (layer) => layer.dispose?.()));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { overlayDriver as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wordup-md/unstorage-drivers",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Isomorphic unstorage drivers for git forges and beyond, loaded one by one through subpath exports.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"forgejo",
|
|
7
|
+
"gitea",
|
|
8
|
+
"gitlab",
|
|
9
|
+
"ipfs",
|
|
10
|
+
"isomorphic",
|
|
11
|
+
"isomorphic-git",
|
|
12
|
+
"opfs",
|
|
13
|
+
"unstorage",
|
|
14
|
+
"unstorage-driver"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/wordup-md/unstorage-drivers#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/wordup-md/unstorage-drivers/issues"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "ManUtopiK <emmanuel.salomon@gmail.com>",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/wordup-md/unstorage-drivers.git"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
"./forgejo": "./dist/forgejo.mjs",
|
|
33
|
+
"./git-fs": "./dist/git-fs.mjs",
|
|
34
|
+
"./gitlab": "./dist/gitlab.mjs",
|
|
35
|
+
"./ipfs": "./dist/ipfs.mjs",
|
|
36
|
+
"./opfs": "./dist/opfs.mjs",
|
|
37
|
+
"./overlay": "./dist/overlay.mjs",
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@helia/unixfs": "^8.0.9",
|
|
45
|
+
"@types/node": "^26.6.5",
|
|
46
|
+
"blockstore-core": "^7.0.1",
|
|
47
|
+
"bumpp": "^12.3.0",
|
|
48
|
+
"isomorphic-git": "^1.44.2",
|
|
49
|
+
"multiformats": "^14.0.5",
|
|
50
|
+
"typescript": "^7.0.2",
|
|
51
|
+
"unstorage": "^1.17.6",
|
|
52
|
+
"vite-plus": "1.3.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@helia/unixfs": ">=8",
|
|
56
|
+
"multiformats": ">=13",
|
|
57
|
+
"unstorage": "^1.17.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@helia/unixfs": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"multiformats": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"devEngines": {
|
|
68
|
+
"packageManager": {
|
|
69
|
+
"name": "pnpm",
|
|
70
|
+
"version": "12.14.0",
|
|
71
|
+
"onFail": "download"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "vp pack",
|
|
76
|
+
"dev": "vp pack --watch",
|
|
77
|
+
"test": "vp test",
|
|
78
|
+
"check": "vp check",
|
|
79
|
+
"release": "bumpp"
|
|
80
|
+
}
|
|
6
81
|
}
|