mikser-io-drive 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -0
- package/index.js +219 -0
- package/lib/authenticator.js +116 -0
- package/lib/files.js +746 -0
- package/lib/staged-writes.js +95 -0
- package/litmus-server.mjs +74 -0
- package/package.json +45 -0
- package/test/atomic-writes.test.js +140 -0
- package/test/derived.test.js +164 -0
- package/test/drive.test.js +233 -0
- package/test/files.test.js +269 -0
- package/test/protocol.test.js +237 -0
- package/test/real-client.test.js +207 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { rename, unlink, open } from 'node:fs/promises'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Make a PUT atomic from the filesystem's point of view.
|
|
6
|
+
//
|
|
7
|
+
// @nephele/adapter-file-system writes straight to the destination:
|
|
8
|
+
//
|
|
9
|
+
// const handle = await fsp.open(this.absolutePath, 'w') // truncates NOW
|
|
10
|
+
// input.pipe(handle.createWriteStream())
|
|
11
|
+
//
|
|
12
|
+
// Measured against a trickling 512KB upload, that has two consequences, and
|
|
13
|
+
// the second is data loss:
|
|
14
|
+
//
|
|
15
|
+
// 1. The file is visible while it grows — 65536, 131072, … 524288 bytes.
|
|
16
|
+
// These folders are mikser sources, so the watcher can import a
|
|
17
|
+
// half-written file and render a truncated page.
|
|
18
|
+
// 2. An interrupted overwrite DESTROYS what was there. `'w'` truncates at
|
|
19
|
+
// open, so a 1600-byte file whose upload died after 196608 bytes was
|
|
20
|
+
// left as 196608 bytes of the new content. Not the old file, not an
|
|
21
|
+
// error — a corrupted file, silently.
|
|
22
|
+
//
|
|
23
|
+
// Both go away by writing to a sibling temp file and renaming on success.
|
|
24
|
+
// rename(2) within a directory is atomic: the watcher sees the file appear
|
|
25
|
+
// complete or not at all, and a failed upload leaves the original untouched
|
|
26
|
+
// because it was never opened for writing.
|
|
27
|
+
//
|
|
28
|
+
// A sibling rather than the OS temp dir, because rename is only atomic within
|
|
29
|
+
// one filesystem and /tmp is frequently another mount.
|
|
30
|
+
export function stageWrites(resource, { onFailure } = {}) {
|
|
31
|
+
if (typeof resource?.setStream !== 'function') return resource
|
|
32
|
+
|
|
33
|
+
return new Proxy(resource, {
|
|
34
|
+
get(target, prop, receiver) {
|
|
35
|
+
if (prop !== 'setStream') {
|
|
36
|
+
const value = Reflect.get(target, prop, receiver)
|
|
37
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return async function setStream(input, user, ...rest) {
|
|
41
|
+
const finalPath = target.absolutePath
|
|
42
|
+
// No path to stage next to — a virtual or non-filesystem
|
|
43
|
+
// resource. Fall through rather than guess.
|
|
44
|
+
if (!finalPath) return target.setStream(input, user, ...rest)
|
|
45
|
+
|
|
46
|
+
const tempPath = path.join(
|
|
47
|
+
path.dirname(finalPath),
|
|
48
|
+
`.${path.basename(finalPath)}.${randomBytes(6).toString('hex')}.part`,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
let handle
|
|
52
|
+
try {
|
|
53
|
+
handle = await open(tempPath, 'wx')
|
|
54
|
+
await new Promise((resolve, reject) => {
|
|
55
|
+
const stream = handle.createWriteStream()
|
|
56
|
+
stream.on('error', reject)
|
|
57
|
+
stream.on('close', resolve)
|
|
58
|
+
input.on('error', (err) => { stream.destroy(); reject(err) })
|
|
59
|
+
input.pipe(stream)
|
|
60
|
+
})
|
|
61
|
+
await handle.close()
|
|
62
|
+
handle = null
|
|
63
|
+
|
|
64
|
+
// The atomic moment. Everything before this is invisible
|
|
65
|
+
// to the watcher because the temp name is dot-prefixed
|
|
66
|
+
// and suffixed .part; everything after is a complete file.
|
|
67
|
+
await rename(tempPath, finalPath)
|
|
68
|
+
|
|
69
|
+
// The adapter caches these off the old file.
|
|
70
|
+
target.etag = undefined
|
|
71
|
+
target.stats = undefined
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await handle?.close().catch(() => {})
|
|
74
|
+
await unlink(tempPath).catch(() => {})
|
|
75
|
+
onFailure?.(err, finalPath)
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Wrap an adapter so every resource it hands out stages its writes.
|
|
84
|
+
export function withStagedWrites(adapter, options = {}) {
|
|
85
|
+
const wrap = (method) => async (...args) => stageWrites(await adapter[method](...args), options)
|
|
86
|
+
return new Proxy(adapter, {
|
|
87
|
+
get(target, prop, receiver) {
|
|
88
|
+
if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
|
|
89
|
+
return wrap(prop)
|
|
90
|
+
}
|
|
91
|
+
const value = Reflect.get(target, prop, receiver)
|
|
92
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Target for an external WebDAV compliance run.
|
|
2
|
+
//
|
|
3
|
+
// node litmus-server.mjs # boots on :8399
|
|
4
|
+
// TESTS=props litmus http://127.0.0.1:8399/webdav/compliant/ litmus litmus-pw
|
|
5
|
+
//
|
|
6
|
+
// litmus (the neon project's suite) is not a dependency — install it from your
|
|
7
|
+
// distro. Scores are recorded in the README; this file is what produced them,
|
|
8
|
+
// kept so they can be reproduced rather than taken on faith.
|
|
9
|
+
//
|
|
10
|
+
// Boots the WebDAV server on a fixed port for an external compliance run.
|
|
11
|
+
// Two endpoints, because they should NOT score the same:
|
|
12
|
+
//
|
|
13
|
+
// /webdav/default properties+locks 'emulate' — the shipped default
|
|
14
|
+
// /webdav/compliant properties+locks 'meta-files' — full storage
|
|
15
|
+
//
|
|
16
|
+
// litmus's `props` suite sets a dead property and reads it back. 'emulate'
|
|
17
|
+
// reports success without storing, so it is expected to fail those — that is
|
|
18
|
+
// the documented trade, and a compliance suite is exactly the thing that
|
|
19
|
+
// should say so out loud.
|
|
20
|
+
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import bcrypt from 'bcryptjs'
|
|
24
|
+
import { runtime } from 'mikser-io'
|
|
25
|
+
import { auth } from 'mikser-io-auth'
|
|
26
|
+
import { webdav } from './index.js'
|
|
27
|
+
|
|
28
|
+
const PORT = Number(process.env.PORT ?? 8399)
|
|
29
|
+
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'litmus-'))
|
|
31
|
+
// A folder PER endpoint. Sharing one made the two suites contaminate each
|
|
32
|
+
// other's leftover /litmus/ collection, which showed up as a suite aborting
|
|
33
|
+
// at `begin` — a harness bug that looked like a server bug.
|
|
34
|
+
await mkdir(path.join(dir, 'default'), { recursive: true })
|
|
35
|
+
await mkdir(path.join(dir, 'compliant'), { recursive: true })
|
|
36
|
+
await mkdir(path.join(dir, 'runtime'), { recursive: true })
|
|
37
|
+
await writeFile(path.join(dir, 'users.htpasswd'), `litmus:${bcrypt.hashSync('litmus-pw', 10)}\n`)
|
|
38
|
+
|
|
39
|
+
const { default: express } = await import('express')
|
|
40
|
+
const app = express()
|
|
41
|
+
runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
|
|
42
|
+
runtime.config = {}
|
|
43
|
+
runtime.engine = { logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
|
|
44
|
+
|
|
45
|
+
const identity = auth({}) // no capability map → authenticated users unscoped
|
|
46
|
+
const plugin = webdav({
|
|
47
|
+
endpoints: {
|
|
48
|
+
default: { folder: 'default' },
|
|
49
|
+
compliant: { folder: 'compliant', properties: 'meta-files', locks: 'meta-files' },
|
|
50
|
+
},
|
|
51
|
+
auth: identity,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const load = [], loaded = []
|
|
55
|
+
const core = {
|
|
56
|
+
runtime,
|
|
57
|
+
onLoad: (c) => load.push(c),
|
|
58
|
+
onLoaded: (c) => loaded.push(c),
|
|
59
|
+
useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
|
|
60
|
+
}
|
|
61
|
+
identity(core)
|
|
62
|
+
plugin(core)
|
|
63
|
+
for (const c of load) await c()
|
|
64
|
+
for (const c of loaded) await c()
|
|
65
|
+
|
|
66
|
+
const server = app.listen(PORT, () => {
|
|
67
|
+
console.log(`litmus target ready on http://127.0.0.1:${PORT}`)
|
|
68
|
+
console.log(` working folder: ${dir}`)
|
|
69
|
+
console.log(` credentials: litmus / litmus-pw`)
|
|
70
|
+
console.log(` endpoints: /webdav/default /webdav/compliant`)
|
|
71
|
+
})
|
|
72
|
+
// litmus uploads and holds connections; do not let node's 5-minute cap
|
|
73
|
+
// truncate a suite mid-run.
|
|
74
|
+
server.requestTimeout = 30 * 60 * 1000
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-drive",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-drive.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mikser",
|
|
16
|
+
"mikser-io",
|
|
17
|
+
"webdav",
|
|
18
|
+
"dav",
|
|
19
|
+
"nephele"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-drive/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"mikser-io": "^9.7.0",
|
|
29
|
+
"zod": "^4.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@nephele/adapter-file-system": "1.0.0-alpha.67",
|
|
33
|
+
"@nephele/plugin-read-only": "1.0.0-alpha.67",
|
|
34
|
+
"nephele": "1.0.0-alpha.67"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"bcryptjs": "^3.0.0",
|
|
38
|
+
"express": "^5.2.1",
|
|
39
|
+
"globby": "^15.0.0",
|
|
40
|
+
"mikser-io": "file:../mikser-io",
|
|
41
|
+
"mikser-io-auth": "file:../mikser-io-auth",
|
|
42
|
+
"webdav": "^5.10.0",
|
|
43
|
+
"zod": "^4.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, writeFile, rm, mkdir, stat, readFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import bcrypt from 'bcryptjs'
|
|
7
|
+
|
|
8
|
+
import { runtime } from 'mikser-io'
|
|
9
|
+
import { auth } from 'mikser-io-auth'
|
|
10
|
+
import { drive } from '../index.js'
|
|
11
|
+
|
|
12
|
+
// These two properties are the reason lib/staged-writes.js exists. The
|
|
13
|
+
// underlying adapter writes with open(path, 'w') straight to the destination,
|
|
14
|
+
// which was measured to (1) expose a growing partial file and (2) destroy the
|
|
15
|
+
// previous contents when an upload is interrupted. Both are silent, and both
|
|
16
|
+
// matter because these folders are mikser sources.
|
|
17
|
+
let server, port, dir, content
|
|
18
|
+
const AUTH = 'Basic ' + Buffer.from('alice:alice-pw').toString('base64')
|
|
19
|
+
const CHUNK = 65536
|
|
20
|
+
const CHUNKS = 8
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-drive-atomic-'))
|
|
24
|
+
content = path.join(dir, 'documents')
|
|
25
|
+
await mkdir(content, { recursive: true })
|
|
26
|
+
await mkdir(path.join(dir, 'runtime'), { recursive: true })
|
|
27
|
+
await writeFile(path.join(dir, 'users.htpasswd'), `alice:${bcrypt.hashSync('alice-pw', 10)}\n`)
|
|
28
|
+
|
|
29
|
+
const { default: express } = await import('express')
|
|
30
|
+
const app = express()
|
|
31
|
+
runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
|
|
32
|
+
runtime.config = { ...runtime.config }
|
|
33
|
+
runtime.engine = { ...runtime.engine, logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
|
|
34
|
+
|
|
35
|
+
const identity = auth({})
|
|
36
|
+
const plugin = drive({ endpoints: { content: { folder: 'documents' } }, auth: identity })
|
|
37
|
+
const load = [], loaded = []
|
|
38
|
+
const core = {
|
|
39
|
+
runtime,
|
|
40
|
+
onLoad: (c) => load.push(c),
|
|
41
|
+
onLoaded: (c) => loaded.push(c),
|
|
42
|
+
useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
|
|
43
|
+
}
|
|
44
|
+
identity(core)
|
|
45
|
+
plugin(core)
|
|
46
|
+
for (const c of load) await c()
|
|
47
|
+
for (const c of loaded) await c()
|
|
48
|
+
|
|
49
|
+
server = await new Promise(r => { const s = app.listen(0, () => r(s)) })
|
|
50
|
+
port = server.address().port
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
after(async () => {
|
|
54
|
+
await new Promise(r => server?.close(r))
|
|
55
|
+
await rm(dir, { recursive: true, force: true })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// A body that trickles, so the upload is observable while in flight.
|
|
59
|
+
function slowBody({ chunks = CHUNKS, size = CHUNK, gapMs = 120, abortAt = null } = {}) {
|
|
60
|
+
let i = 0
|
|
61
|
+
return new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
if (abortAt != null && i === abortAt) {
|
|
64
|
+
controller.error(new Error('client vanished'))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (i >= chunks) { controller.close(); return }
|
|
68
|
+
controller.enqueue(new Uint8Array(size).fill(65 + (i % 26)))
|
|
69
|
+
i++
|
|
70
|
+
await new Promise(r => setTimeout(r, gapMs))
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const put = (name, body) => fetch(`http://127.0.0.1:${port}/drive/content/${name}`, {
|
|
76
|
+
method: 'PUT', headers: { authorization: AUTH }, body, duplex: 'half',
|
|
77
|
+
})
|
|
78
|
+
const sizeOf = async (p) => { try { return (await stat(p)).size } catch { return null } }
|
|
79
|
+
|
|
80
|
+
describe('a partial upload is never visible at the destination', () => {
|
|
81
|
+
it('the target path does not exist until the upload completes', async () => {
|
|
82
|
+
const target = path.join(content, 'big.md')
|
|
83
|
+
const seen = []
|
|
84
|
+
const poll = setInterval(async () => seen.push(await sizeOf(target)), 100)
|
|
85
|
+
const res = await put('big.md', slowBody())
|
|
86
|
+
clearInterval(poll)
|
|
87
|
+
await new Promise(r => setTimeout(r, 50))
|
|
88
|
+
|
|
89
|
+
assert.ok([201, 204].includes(res.status), `unexpected ${res.status}`)
|
|
90
|
+
assert.equal(await sizeOf(target), CHUNK * CHUNKS, 'the whole file landed')
|
|
91
|
+
|
|
92
|
+
// Without staging, this sequence was [65536, 131072, … 524288].
|
|
93
|
+
const partial = seen.filter(s => s !== null && s > 0 && s < CHUNK * CHUNKS)
|
|
94
|
+
assert.deepEqual(partial, [], `a growing file was visible: ${JSON.stringify(seen)}`)
|
|
95
|
+
assert.ok(seen.length >= 3, 'the poll must have run while the upload was in flight')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves no staging file behind on success', async () => {
|
|
99
|
+
await put('clean.md', slowBody({ chunks: 2, gapMs: 20 }))
|
|
100
|
+
const leftovers = (await readdir(content)).filter(f => f.endsWith('.part'))
|
|
101
|
+
assert.deepEqual(leftovers, [])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('an interrupted overwrite leaves the original intact', () => {
|
|
106
|
+
it('does not truncate or corrupt the file that was there', async () => {
|
|
107
|
+
// The measured failure without staging: a 1600-byte file became
|
|
108
|
+
// 196608 bytes of the new content. Not the old file, not an error.
|
|
109
|
+
const target = path.join(content, 'precious.md')
|
|
110
|
+
const original = '# original content that matters\n'.repeat(50)
|
|
111
|
+
await writeFile(target, original)
|
|
112
|
+
|
|
113
|
+
await assert.rejects(() => put('precious.md', slowBody({ abortAt: 3 })))
|
|
114
|
+
await new Promise(r => setTimeout(r, 200))
|
|
115
|
+
|
|
116
|
+
assert.equal(await readFile(target, 'utf8'), original)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('cleans up the staging file after a failed upload', async () => {
|
|
120
|
+
await assert.rejects(() => put('doomed.md', slowBody({ abortAt: 2 })))
|
|
121
|
+
await new Promise(r => setTimeout(r, 200))
|
|
122
|
+
const leftovers = (await readdir(content)).filter(f => f.endsWith('.part'))
|
|
123
|
+
assert.deepEqual(leftovers, [], 'a failed upload must not leave a .part file')
|
|
124
|
+
assert.equal(await sizeOf(path.join(content, 'doomed.md')), null,
|
|
125
|
+
'and must not create the destination at all')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('atomicWrites: false restores the adapter behaviour', () => {
|
|
130
|
+
it('is available as an escape hatch', async () => {
|
|
131
|
+
// Documented rather than recommended: the point is that the default
|
|
132
|
+
// is the safe one and the unsafe one has to be asked for.
|
|
133
|
+
const { withStagedWrites, stageWrites } = await import('../index.js')
|
|
134
|
+
assert.equal(typeof withStagedWrites, 'function')
|
|
135
|
+
assert.equal(typeof stageWrites, 'function')
|
|
136
|
+
// A resource with no setStream passes through untouched.
|
|
137
|
+
const plain = { absolutePath: '/x' }
|
|
138
|
+
assert.equal(stageWrites(plain), plain)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
|
|
9
|
+
// `derived` is a passthrough of what the assets plugin stamped, and it was the
|
|
10
|
+
// one field in the add response I could not verify by unit test: it only exists
|
|
11
|
+
// when a real preset matched and a real derivative was produced. So this runs
|
|
12
|
+
// the whole pipeline — files → assets → renderPreset → sharp — against a live
|
|
13
|
+
// server and asks the tool.
|
|
14
|
+
//
|
|
15
|
+
// The read is three lines. What this pins is that those three lines name the
|
|
16
|
+
// field the pipeline actually writes, which is the half that silently rots when
|
|
17
|
+
// the plugin changes shape.
|
|
18
|
+
//
|
|
19
|
+
// Two mistakes made while building it, both of which produce SILENCE rather
|
|
20
|
+
// than an error and are worth knowing:
|
|
21
|
+
//
|
|
22
|
+
// - a preset is a MODULE with a default export that does the work, not an
|
|
23
|
+
// inline options object. Without the default, renderPreset resolves
|
|
24
|
+
// `runtime.preset` to undefined and every render throws.
|
|
25
|
+
// - `match` runs against the entity ID. `outputFolder` prefixes `name` and
|
|
26
|
+
// `meta.url` but NOT `id`, so a glob written against the URL shape
|
|
27
|
+
// (/media/**) matches nothing, produces no variant, and reports only that
|
|
28
|
+
// none matched.
|
|
29
|
+
|
|
30
|
+
// sharp is mikser-io's dependency, not this package's — the fixture needs it
|
|
31
|
+
// because a preset does the actual image work. Resolved from there rather than
|
|
32
|
+
// from here, and the fixture's node_modules is built from mikser-io's so the
|
|
33
|
+
// spawned build finds it too.
|
|
34
|
+
const require_ = createRequire(import.meta.url)
|
|
35
|
+
const ENGINE = path.resolve(import.meta.dirname, '..', '..', 'mikser-io')
|
|
36
|
+
const hasSharp = (() => {
|
|
37
|
+
try { require_.resolve('sharp', { paths: [ENGINE] }); return true } catch { return false }
|
|
38
|
+
})()
|
|
39
|
+
|
|
40
|
+
const PORT = 4211
|
|
41
|
+
const PNG = Buffer.from(
|
|
42
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
|
43
|
+
'base64')
|
|
44
|
+
|
|
45
|
+
let dir, server
|
|
46
|
+
|
|
47
|
+
async function rpc(token, method, params, session) {
|
|
48
|
+
const headers = {
|
|
49
|
+
'content-type': 'application/json',
|
|
50
|
+
accept: 'application/json, text/event-stream',
|
|
51
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
52
|
+
...(session.id ? { 'mcp-session-id': session.id } : {}),
|
|
53
|
+
}
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/mcp`,
|
|
55
|
+
{ method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: session.n++, method, params }) })
|
|
56
|
+
if (res.headers.get('mcp-session-id')) session.id = res.headers.get('mcp-session-id')
|
|
57
|
+
const text = await res.text()
|
|
58
|
+
const frames = text.split('\n').filter(l => l.startsWith('data:')).map(l => JSON.parse(l.slice(5)))
|
|
59
|
+
return (frames.at(-1) ?? JSON.parse(text)).result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
before(async (t) => {
|
|
63
|
+
if (!hasSharp) return
|
|
64
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-derived-'))
|
|
65
|
+
await mkdir(path.join(dir, 'media'), { recursive: true })
|
|
66
|
+
await mkdir(path.join(dir, 'presets'), { recursive: true })
|
|
67
|
+
await mkdir(path.join(dir, 'documents'), { recursive: true })
|
|
68
|
+
|
|
69
|
+
// node_modules by symlink, so the fixture resolves the working copies.
|
|
70
|
+
const here = path.resolve(import.meta.dirname, '..')
|
|
71
|
+
await mkdir(path.join(dir, 'node_modules'), { recursive: true })
|
|
72
|
+
const { symlink, readdir } = await import('node:fs/promises')
|
|
73
|
+
// Both donors: this package's own deps, plus the engine's — which is where
|
|
74
|
+
// sharp lives, and a preset cannot run without it.
|
|
75
|
+
for (const donor of [path.join(here, 'node_modules'), path.join(ENGINE, 'node_modules')]) {
|
|
76
|
+
for (const entry of await readdir(donor).catch(() => [])) {
|
|
77
|
+
await symlink(path.join(donor, entry), path.join(dir, 'node_modules', entry)).catch(() => {})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const pkg of ['mikser-io', 'mikser-io-mcp', 'mikser-io-drive']) {
|
|
81
|
+
await rm(path.join(dir, 'node_modules', pkg), { recursive: true, force: true })
|
|
82
|
+
await symlink(path.resolve(here, '..', pkg), path.join(dir, 'node_modules', pkg)).catch(() => {})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A preset is a module whose DEFAULT export does the work.
|
|
86
|
+
await writeFile(path.join(dir, 'presets/web.js'),
|
|
87
|
+
"import sharp from 'sharp'\n"
|
|
88
|
+
+ 'export const revision = 1\n'
|
|
89
|
+
+ "export const format = 'webp'\n"
|
|
90
|
+
+ 'export const options = { width: 8 }\n'
|
|
91
|
+
+ 'export default ({ entity: { source, destination }, options }) =>\n'
|
|
92
|
+
+ ' sharp(source).resize(options).webp().toFile(destination)\n')
|
|
93
|
+
|
|
94
|
+
// `match` against the ID shape, which the files plugin builds from the path
|
|
95
|
+
// inside filesFolder — NOT the URL shape.
|
|
96
|
+
await writeFile(path.join(dir, 'mikser.config.js'),
|
|
97
|
+
"import { files, assets, renderPreset } from 'mikser-io'\n"
|
|
98
|
+
+ "import { mcp } from 'mikser-io-mcp'\n"
|
|
99
|
+
+ "import { drive } from 'mikser-io-drive'\n"
|
|
100
|
+
+ 'export default async () => ({\n'
|
|
101
|
+
+ ' plugins: [\n'
|
|
102
|
+
+ " mcp({ base: '' }),\n"
|
|
103
|
+
+ " files({ filesFolder: 'media', outputFolder: 'media' }),\n"
|
|
104
|
+
+ " assets({ assetsFolder: 'derived', presetsFolder: 'presets',\n"
|
|
105
|
+
+ " presets: { web: { match: ['/files/**/*.png'] } } }),\n"
|
|
106
|
+
+ ' renderPreset(),\n'
|
|
107
|
+
+ " drive({ allowRemote: true, endpoints: { media: { folder: 'media' } } }),\n"
|
|
108
|
+
+ ' ],\n'
|
|
109
|
+
+ '})\n')
|
|
110
|
+
|
|
111
|
+
server = spawn(process.execPath, [
|
|
112
|
+
path.join(dir, 'node_modules/mikser-io/app.js'),
|
|
113
|
+
'--working-folder', dir, '--output-folder', 'out', '--runtime-folder', 'runtime',
|
|
114
|
+
'--watch', '--server', String(PORT), '--url', `http://127.0.0.1:${PORT}`,
|
|
115
|
+
], { cwd: dir, stdio: 'ignore' })
|
|
116
|
+
|
|
117
|
+
// Wait for it to answer rather than sleeping a guess.
|
|
118
|
+
for (let i = 0; i < 60; i++) {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/mcp`, { method: 'POST' })
|
|
121
|
+
if (res.status) break
|
|
122
|
+
} catch { await new Promise(r => setTimeout(r, 1000)) }
|
|
123
|
+
}
|
|
124
|
+
await new Promise(r => setTimeout(r, 3000))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
after(async () => {
|
|
128
|
+
server?.kill()
|
|
129
|
+
if (dir) await rm(dir, { recursive: true, force: true })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('derived — the preset passthrough', { skip: hasSharp ? false : 'sharp is not installed' }, () => {
|
|
133
|
+
it('reports the variant a preset actually produced', async () => {
|
|
134
|
+
const session = { id: null, n: 1 }
|
|
135
|
+
await rpc(null, 'initialize',
|
|
136
|
+
{ protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } }, session)
|
|
137
|
+
await fetch(`http://127.0.0.1:${PORT}/mcp`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream',
|
|
140
|
+
'mcp-session-id': session.id },
|
|
141
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const result = await rpc(null, 'tools/call', {
|
|
145
|
+
name: 'mikser_drive_add',
|
|
146
|
+
arguments: { endpoint: 'media', folder: 'shots',
|
|
147
|
+
files: [{ name: 'hero.png', base64: PNG.toString('base64'), mime: 'image/png' }] },
|
|
148
|
+
}, session)
|
|
149
|
+
assert.equal(result?.isError, undefined, result?.content?.[0]?.text)
|
|
150
|
+
|
|
151
|
+
const body = JSON.parse(result.content[0].text)
|
|
152
|
+
const stored = body.files[0]
|
|
153
|
+
assert.equal(stored.imported, true, 'the cycle must have imported it')
|
|
154
|
+
assert.equal(stored.reference, '/media/shots/hero.png')
|
|
155
|
+
|
|
156
|
+
// The field this whole fixture exists for.
|
|
157
|
+
assert.ok(stored.derived, `expected derived variants, got: ${stored.derivedNote ?? '(nothing)'}`)
|
|
158
|
+
assert.ok(stored.derived.web, `expected a "web" variant, got ${JSON.stringify(stored.derived)}`)
|
|
159
|
+
assert.match(stored.derived.web, /\.webp$/,
|
|
160
|
+
'the preset declares format webp, so the variant URL should carry that extension')
|
|
161
|
+
assert.notEqual(stored.derived.web, stored.reference,
|
|
162
|
+
'the variant must be a different URL from the original, or nothing was derived')
|
|
163
|
+
})
|
|
164
|
+
})
|