@hoardodile/host 0.1.2 → 0.1.4
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/dist/chunks/worker-entry.mjs +6 -1
- package/dist/hoard/index.d.ts +24 -19
- package/dist/hoard/index.js +22 -4
- package/dist/hoard/index.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +77 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/hoard/index.ts +4 -1
- package/src/hoard/paths.ts +37 -11
- package/src/hoard/versioned-folder-ops.ts +29 -6
- package/src/hooks.test.ts +70 -0
- package/src/hooks.ts +26 -0
- package/src/install-api.ts +68 -0
- package/src/sandbox/host.ts +30 -1
- package/src/sandbox/worker-entry.mjs +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoardodile/host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"file-type": "^22.0.2",
|
|
68
68
|
"yauzl": "^3.4.0",
|
|
69
69
|
"yazl": "^3.3.1",
|
|
70
|
-
"@hoardodile/sdk-types": "0.1.
|
|
70
|
+
"@hoardodile/sdk-types": "0.1.4"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@derhuerst/ffprobe-static": "^5.3.0",
|
package/src/hoard/index.ts
CHANGED
|
@@ -75,7 +75,10 @@ export {
|
|
|
75
75
|
versionedPath,
|
|
76
76
|
writeActiveVersion,
|
|
77
77
|
} from "./version.ts"
|
|
78
|
-
export type {
|
|
78
|
+
export type {
|
|
79
|
+
VersionedFolderOps,
|
|
80
|
+
VersionedFolderSubjectKind,
|
|
81
|
+
} from "./versioned-folder-ops.ts"
|
|
79
82
|
export {
|
|
80
83
|
archiveStaleFiles,
|
|
81
84
|
buildVersionedFolderOps,
|
package/src/hoard/paths.ts
CHANGED
|
@@ -71,7 +71,8 @@ function hasForbiddenVisibleChar(segment: string): boolean {
|
|
|
71
71
|
* per-version database snapshot), `db-backups/` (manual backups,
|
|
72
72
|
* only kept for the current version), `snapshots/` (automatic daily
|
|
73
73
|
* snapshots, only kept for the current version), `resources/<id>/`,
|
|
74
|
-
* `characters/<id>/`, `
|
|
74
|
+
* `characters/<id>/`, `tags/<id>/` (tag art — see {@link VersionPaths.tag}),
|
|
75
|
+
* `plugins/<id>/` (installed content plugins
|
|
75
76
|
* frozen with that version; the builtin `file` plugin is not stored
|
|
76
77
|
* here). Old versions are FROZEN: no writes ever land in
|
|
77
78
|
* `versions/<v>` once a `versions/<v+1>` exists.
|
|
@@ -129,10 +130,18 @@ export type VersionPaths = {
|
|
|
129
130
|
resources(): string
|
|
130
131
|
/** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
|
|
131
132
|
characters(): string
|
|
133
|
+
/** Root folder of all tags in this version: `<root>/versions/<v>/tags`. */
|
|
134
|
+
tags(): string
|
|
132
135
|
/** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
|
|
133
136
|
documents(): string
|
|
134
137
|
/** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
|
|
135
138
|
character(id: string): string
|
|
139
|
+
/**
|
|
140
|
+
* Root folder of a tag: `<root>/versions/<v>/tags/<id>`. Holds the
|
|
141
|
+
* tag's single image slot (`image.<ext>`), the same convention as
|
|
142
|
+
* character avatar/fullbody slots.
|
|
143
|
+
*/
|
|
144
|
+
tag(id: string): string
|
|
136
145
|
/** Root of manual backups: `<root>/versions/<v>/db-backups`. */
|
|
137
146
|
dbBackups(): string
|
|
138
147
|
/** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
|
|
@@ -147,7 +156,7 @@ export type VersionPaths = {
|
|
|
147
156
|
* delete cannot remove a folder whose files live under frozen past
|
|
148
157
|
* archives.
|
|
149
158
|
*/
|
|
150
|
-
deletedMarker(kind: "resources" | "characters", id: string): string
|
|
159
|
+
deletedMarker(kind: "resources" | "characters" | "tags", id: string): string
|
|
151
160
|
/** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
|
|
152
161
|
document(id: string): string
|
|
153
162
|
/**
|
|
@@ -175,12 +184,12 @@ export type LocalPaths = {
|
|
|
175
184
|
logs(): string
|
|
176
185
|
/**
|
|
177
186
|
* Path to a local derived cover/thumb variant:
|
|
178
|
-
* `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
|
|
187
|
+
* `<localRoot>/cache/<resources|characters|tags>/<id>/<variant>.<format>`.
|
|
179
188
|
* Holds synthesized covers (resource covers, character avatars and
|
|
180
|
-
* fullbody images); re-rendered when cleared.
|
|
189
|
+
* fullbody images, tag art); re-rendered when cleared.
|
|
181
190
|
*/
|
|
182
191
|
localCover(
|
|
183
|
-
subjectKind: "resource" | "character",
|
|
192
|
+
subjectKind: "resource" | "character" | "tag",
|
|
184
193
|
id: string,
|
|
185
194
|
variant: string,
|
|
186
195
|
format?: string,
|
|
@@ -215,6 +224,13 @@ export type LocalPaths = {
|
|
|
215
224
|
* (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
|
|
216
225
|
*/
|
|
217
226
|
character(id: string): string
|
|
227
|
+
/**
|
|
228
|
+
* Root of the local per-tag directory:
|
|
229
|
+
* `<localRoot>/cache/tags/<id>`.
|
|
230
|
+
* Holds (a) versioned copies of replaced tag art and (b) the tag
|
|
231
|
+
* thumbnail variant (`image.avif`).
|
|
232
|
+
*/
|
|
233
|
+
tag(id: string): string
|
|
218
234
|
/** Root of the trash: `<localRoot>/trash`. */
|
|
219
235
|
trash(): string
|
|
220
236
|
/** Path to a single trashed item: `<localRoot>/trash/<id>`. */
|
|
@@ -356,8 +372,10 @@ export function createStoragePaths(
|
|
|
356
372
|
join(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
|
|
357
373
|
resources: () => join(vRoot, "resources"),
|
|
358
374
|
characters: () => join(vRoot, "characters"),
|
|
375
|
+
tags: () => join(vRoot, "tags"),
|
|
359
376
|
documents: () => join(vRoot, "documents"),
|
|
360
377
|
character: (id) => join(vRoot, "characters", assertSafeSegment(id)),
|
|
378
|
+
tag: (id) => join(vRoot, "tags", assertSafeSegment(id)),
|
|
361
379
|
dbBackups: () => join(vRoot, "db-backups"),
|
|
362
380
|
dbBackup: (name) => join(vRoot, "db-backups", assertSafeSegment(name)),
|
|
363
381
|
snapshots: () => join(vRoot, "snapshots"),
|
|
@@ -401,6 +419,7 @@ export function createStoragePaths(
|
|
|
401
419
|
join(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
|
|
402
420
|
resource: (id) => join(cacheRoot, "resources", assertSafeSegment(id)),
|
|
403
421
|
character: (id) => join(cacheRoot, "characters", assertSafeSegment(id)),
|
|
422
|
+
tag: (id) => join(cacheRoot, "tags", assertSafeSegment(id)),
|
|
404
423
|
trash: () => join(localRoot, "trash"),
|
|
405
424
|
trashItem: (id) => join(localRoot, "trash", assertSafeSegment(id)),
|
|
406
425
|
tmp: () => join(cacheRoot, "tmp"),
|
|
@@ -465,13 +484,20 @@ export function createStoragePaths(
|
|
|
465
484
|
}
|
|
466
485
|
|
|
467
486
|
/**
|
|
468
|
-
* Map a {@link LocalPaths.
|
|
469
|
-
* Variants now live flat inside the per-id local directory
|
|
470
|
-
* `thumbs/` parent), so `resource` -> `resources
|
|
471
|
-
* `characters` (
|
|
487
|
+
* Map a {@link LocalPaths.localCover} subjectKind onto its on-disk
|
|
488
|
+
* subdirectory. Variants now live flat inside the per-id local directory
|
|
489
|
+
* (no enclosing `thumbs/` parent), so `resource` -> `resources`,
|
|
490
|
+
* `character` -> `characters`, `tag` -> `tags` (all plural to match the
|
|
491
|
+
* storage layout convention).
|
|
472
492
|
*/
|
|
473
|
-
function localCoverSubjectDir(
|
|
474
|
-
|
|
493
|
+
function localCoverSubjectDir(
|
|
494
|
+
subjectKind: "resource" | "character" | "tag",
|
|
495
|
+
): string {
|
|
496
|
+
return subjectKind === "resource"
|
|
497
|
+
? "resources"
|
|
498
|
+
: subjectKind === "character"
|
|
499
|
+
? "characters"
|
|
500
|
+
: "tags"
|
|
475
501
|
}
|
|
476
502
|
|
|
477
503
|
/**
|
|
@@ -3,6 +3,8 @@ import { extname, join } from "node:path"
|
|
|
3
3
|
import type { StoragePaths } from "./paths.ts"
|
|
4
4
|
import { writeVersioned } from "./write-versioned.ts"
|
|
5
5
|
|
|
6
|
+
export type VersionedFolderSubjectKind = "resource" | "character" | "tag"
|
|
7
|
+
|
|
6
8
|
export type VersionedFolderOps = {
|
|
7
9
|
/** Ensure the current-version entity folder exists. */
|
|
8
10
|
ensureFolder(id: string): Promise<void>
|
|
@@ -24,7 +26,7 @@ export type VersionedFolderOps = {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
|
-
* The four lifecycle operations shared by the resource and
|
|
29
|
+
* The four lifecycle operations shared by the resource, character and tag
|
|
28
30
|
* file-system layers. They differ only in which versioned folder they
|
|
29
31
|
* target and how the trash / placeholder names are derived.
|
|
30
32
|
*
|
|
@@ -35,17 +37,38 @@ export type VersionedFolderOps = {
|
|
|
35
37
|
* `readOnly` is a live `{ current: boolean }` ref (the server's runtime
|
|
36
38
|
* read-only flag), so a version switch mid-request re-reads it.
|
|
37
39
|
*/
|
|
40
|
+
const KIND_LAYOUT: Record<
|
|
41
|
+
VersionedFolderSubjectKind,
|
|
42
|
+
{
|
|
43
|
+
readonly trashPrefix: string
|
|
44
|
+
readonly deletedKind: "resources" | "characters" | "tags"
|
|
45
|
+
}
|
|
46
|
+
> = {
|
|
47
|
+
resource: { trashPrefix: "resources-", deletedKind: "resources" },
|
|
48
|
+
character: { trashPrefix: "characters-", deletedKind: "characters" },
|
|
49
|
+
tag: { trashPrefix: "tags-", deletedKind: "tags" },
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
export function buildVersionedFolderOps(
|
|
39
53
|
paths: StoragePaths,
|
|
40
54
|
readOnly: { readonly current: boolean },
|
|
41
|
-
kind:
|
|
55
|
+
kind: VersionedFolderSubjectKind,
|
|
42
56
|
): VersionedFolderOps {
|
|
43
57
|
const folderOf =
|
|
44
58
|
(id: string) =>
|
|
45
|
-
(current: StoragePaths["latest"]): string =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
(current: StoragePaths["latest"]): string => {
|
|
60
|
+
switch (kind) {
|
|
61
|
+
case "resource":
|
|
62
|
+
return current.resource(id)
|
|
63
|
+
case "character":
|
|
64
|
+
return current.character(id)
|
|
65
|
+
case "tag":
|
|
66
|
+
return current.tag(id)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const layout = KIND_LAYOUT[kind]
|
|
70
|
+
const trashPrefix = layout.trashPrefix
|
|
71
|
+
const deletedKind = layout.deletedKind
|
|
49
72
|
|
|
50
73
|
async function ensureFolder(id: string): Promise<void> {
|
|
51
74
|
await writeVersioned(paths, readOnly.current, (current) =>
|
package/src/hooks.test.ts
CHANGED
|
@@ -526,3 +526,73 @@ describe("plugin hooks: imageHashes", () => {
|
|
|
526
526
|
).resolves.toBeUndefined()
|
|
527
527
|
})
|
|
528
528
|
})
|
|
529
|
+
|
|
530
|
+
describe("runInstallHook", () => {
|
|
531
|
+
const installEntry = (onInstall: PluginDefinition["onInstall"]) => ({
|
|
532
|
+
id: PAGES_ID,
|
|
533
|
+
manifest: manifestFor(PAGES_ID, "Pages"),
|
|
534
|
+
enabled: true,
|
|
535
|
+
priority: 50,
|
|
536
|
+
pinned: false,
|
|
537
|
+
color: "",
|
|
538
|
+
missing: false,
|
|
539
|
+
builtin: false,
|
|
540
|
+
dev: false,
|
|
541
|
+
plugin: { ...createPagesPlugin(), onInstall },
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test("invokes onInstall with an install-scoped API (empty file surface)", async () => {
|
|
545
|
+
const seen: ResourceAPI[] = []
|
|
546
|
+
const hooks = createPluginHooks({
|
|
547
|
+
getRegistry: () =>
|
|
548
|
+
buildRegistry([
|
|
549
|
+
installEntry(async (api) => {
|
|
550
|
+
seen.push(api)
|
|
551
|
+
}),
|
|
552
|
+
]),
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
await hooks.runInstallHook(PAGES_ID)
|
|
556
|
+
|
|
557
|
+
expect(seen).toHaveLength(1)
|
|
558
|
+
expect(await seen[0]!.listFileNames()).toEqual([])
|
|
559
|
+
expect(seen[0]!.context.detect).toBeUndefined()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test("no-ops when the plugin declares no onInstall", async () => {
|
|
563
|
+
const hooks = createPluginHooks({
|
|
564
|
+
getRegistry: () => buildRegistry([installEntry(undefined)]),
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test("a throwing hook is logged and swallowed", async () => {
|
|
571
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
572
|
+
try {
|
|
573
|
+
const hooks = createPluginHooks({
|
|
574
|
+
getRegistry: () =>
|
|
575
|
+
buildRegistry([
|
|
576
|
+
installEntry(async () => {
|
|
577
|
+
throw new Error("boom")
|
|
578
|
+
}),
|
|
579
|
+
]),
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
|
|
583
|
+
expect(warn).toHaveBeenCalledWith(
|
|
584
|
+
expect.stringContaining("onInstall failed for plugin"),
|
|
585
|
+
)
|
|
586
|
+
} finally {
|
|
587
|
+
warn.mockRestore()
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test("an unknown plugin id is a no-op", async () => {
|
|
592
|
+
const hooks = createPluginHooks({
|
|
593
|
+
getRegistry: () => buildRegistry([]),
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
|
|
597
|
+
})
|
|
598
|
+
})
|
package/src/hooks.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@hoardodile/sdk-types"
|
|
7
7
|
import type { PluginRegistry, PluginRegistryEntry } from "./api-types.ts"
|
|
8
8
|
import { createCapabilityGuard } from "./capability-guard.ts"
|
|
9
|
+
import { createInstallScopeApi } from "./install-api.ts"
|
|
9
10
|
import type { Detection, ResourceAPI } from "./types.ts"
|
|
10
11
|
|
|
11
12
|
/** Absolute cap of hash rows one resource may contribute (host policy). */
|
|
@@ -161,6 +162,18 @@ export type PluginHooks = {
|
|
|
161
162
|
api: ResourceAPI,
|
|
162
163
|
pluginId: PluginManifestId,
|
|
163
164
|
) => Promise<ImageHashesResult | undefined>
|
|
165
|
+
/**
|
|
166
|
+
* Run the plugin's optional `onInstall` hook — best-effort, invoked
|
|
167
|
+
* by the host after a successful install/update commit (marketplace
|
|
168
|
+
* install/update or a zip upload; never seed/dev plugins). The hook
|
|
169
|
+
* receives an install-scoped {@link ResourceAPI}: no resource is
|
|
170
|
+
* attached (the file surface answers empty, `context.detect` is
|
|
171
|
+
* `undefined`), while the asset methods still work and stay gated by
|
|
172
|
+
* the shared consent dialog. A throwing (or consent-denied) hook is
|
|
173
|
+
* logged via `hookFailureText` and swallowed — the install itself is
|
|
174
|
+
* never failed; plugins must re-check at runtime.
|
|
175
|
+
*/
|
|
176
|
+
readonly runInstallHook: (pluginId: PluginManifestId) => Promise<void>
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
|
|
@@ -364,6 +377,18 @@ export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
|
|
|
364
377
|
)
|
|
365
378
|
}
|
|
366
379
|
|
|
380
|
+
async function runInstallHook(pluginId: PluginManifestId): Promise<void> {
|
|
381
|
+
const entry = getRegistry().getById(pluginId)
|
|
382
|
+
if (entry === undefined) return
|
|
383
|
+
await invokeHook(
|
|
384
|
+
entry,
|
|
385
|
+
entry.plugin.onInstall,
|
|
386
|
+
createInstallScopeApi(),
|
|
387
|
+
"onInstall",
|
|
388
|
+
"warn",
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
367
392
|
return {
|
|
368
393
|
defaultPluginId,
|
|
369
394
|
getEffectiveEntry,
|
|
@@ -376,6 +401,7 @@ export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
|
|
|
376
401
|
runMetaHooks,
|
|
377
402
|
supportsImageHashes,
|
|
378
403
|
runImageHashes,
|
|
404
|
+
runInstallHook,
|
|
379
405
|
}
|
|
380
406
|
}
|
|
381
407
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { pluginAssetError } from "@hoardodile/sdk-types"
|
|
2
|
+
import type { ResourceAPI } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ResourceAPI handed to a plugin's `onInstall` hook: there is no
|
|
6
|
+
* resource attached, so the file surface answers empty (or throws a
|
|
7
|
+
* clear "no resource" error) and `context.detect` is `undefined`.
|
|
8
|
+
*
|
|
9
|
+
* The asset surface stays honest: in the app server the sandbox
|
|
10
|
+
* intercepts these methods before the API object is consulted and
|
|
11
|
+
* routes them to the consent-gated asset service with the owning
|
|
12
|
+
* plugin id — the onInstall download flow is identical to the runtime
|
|
13
|
+
* one. In-process hosts (fixtures, dev runner, CLI) answer
|
|
14
|
+
* `UNAVAILABLE` exactly like every other host without a consent
|
|
15
|
+
* channel.
|
|
16
|
+
*/
|
|
17
|
+
export function createInstallScopeApi(): ResourceAPI {
|
|
18
|
+
const noResource = (method: string): Error =>
|
|
19
|
+
new Error(
|
|
20
|
+
`${method}() — no resource is attached to the onInstall hook; use the asset methods (download/statAsset/readAsset/deleteAsset) for install-time work`,
|
|
21
|
+
)
|
|
22
|
+
return {
|
|
23
|
+
logInfo() {},
|
|
24
|
+
logWarn() {},
|
|
25
|
+
logError() {},
|
|
26
|
+
context: { detect: undefined },
|
|
27
|
+
listFileNames: async () => [],
|
|
28
|
+
readFile: async () => {
|
|
29
|
+
throw noResource("readFile")
|
|
30
|
+
},
|
|
31
|
+
statFile: async () => undefined,
|
|
32
|
+
statFiles: async (paths) => paths.map(() => undefined),
|
|
33
|
+
sniff: async () => undefined,
|
|
34
|
+
probe: async () => ({ kind: "unknown", reason: "unavailable" }),
|
|
35
|
+
hashBytes: async () => {
|
|
36
|
+
throw noResource("hashBytes")
|
|
37
|
+
},
|
|
38
|
+
computeImageHashes: async () => undefined,
|
|
39
|
+
listContainer: async () => {
|
|
40
|
+
throw noResource("listContainer")
|
|
41
|
+
},
|
|
42
|
+
extractArchive: async () => {
|
|
43
|
+
throw noResource("extractArchive")
|
|
44
|
+
},
|
|
45
|
+
download: async () => {
|
|
46
|
+
throw pluginAssetError(
|
|
47
|
+
"UNAVAILABLE",
|
|
48
|
+
"download() — this host has no plugin asset service; only the app server host can download into the plugin vault",
|
|
49
|
+
)
|
|
50
|
+
},
|
|
51
|
+
statAsset: async () => {
|
|
52
|
+
throw unavailableAsset("statAsset")
|
|
53
|
+
},
|
|
54
|
+
readAsset: async () => {
|
|
55
|
+
throw unavailableAsset("readAsset")
|
|
56
|
+
},
|
|
57
|
+
deleteAsset: async () => {
|
|
58
|
+
throw unavailableAsset("deleteAsset")
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function unavailableAsset(method: string): Error {
|
|
64
|
+
return pluginAssetError(
|
|
65
|
+
"UNAVAILABLE",
|
|
66
|
+
`${method}() — this host has no plugin asset vault; only the app server host manages vault files`,
|
|
67
|
+
)
|
|
68
|
+
}
|
package/src/sandbox/host.ts
CHANGED
|
@@ -337,7 +337,14 @@ export function createPluginSandbox(
|
|
|
337
337
|
await teardownChild(previous)
|
|
338
338
|
}
|
|
339
339
|
if (!opts.eager) {
|
|
340
|
-
|
|
340
|
+
// AWAITED, not fire-and-forget: the hook list was probed by
|
|
341
|
+
// this load, and the loader returns the sandboxed definition
|
|
342
|
+
// the moment we resolve. A caller that invokes a hook
|
|
343
|
+
// immediately after (e.g. the post-install `onInstall`) must
|
|
344
|
+
// not find a half-alive child: `ensureLoaded` would see the
|
|
345
|
+
// not-yet-torn-down child and post its invocation into a
|
|
346
|
+
// dying process (rejected as "worker stopped").
|
|
347
|
+
await teardownChild(state)
|
|
341
348
|
}
|
|
342
349
|
return createSandboxedPlugin(state.hooks ?? ["detect"], (hook, api) =>
|
|
343
350
|
invoke(state, hook, api),
|
|
@@ -611,6 +618,28 @@ export function createPluginSandbox(
|
|
|
611
618
|
throw new Error(`plugin ${state.id} sandbox unavailable`)
|
|
612
619
|
}
|
|
613
620
|
|
|
621
|
+
try {
|
|
622
|
+
return await invokeOnChild(state, child, hook, api)
|
|
623
|
+
} finally {
|
|
624
|
+
// An install hook is one-shot and the host may immediately
|
|
625
|
+
// re-commit the same plugin (an update): a kept-alive worker
|
|
626
|
+
// holding the plugin directory (Windows keeps directory ops
|
|
627
|
+
// pinned while a child is alive) would make the vault-move
|
|
628
|
+
// during the next commit fail with EPERM. Drop the child right
|
|
629
|
+
// after an `onInstall`; the next invocation respawns it.
|
|
630
|
+
if (hook === "onInstall") {
|
|
631
|
+
state.respawnTimes = []
|
|
632
|
+
await teardownChild(state)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function invokeOnChild(
|
|
638
|
+
state: PluginState,
|
|
639
|
+
child: ChildProcess,
|
|
640
|
+
hook: HookName,
|
|
641
|
+
api: ResourceAPI,
|
|
642
|
+
): Promise<unknown> {
|
|
614
643
|
const callId = nextCallId++
|
|
615
644
|
return new Promise((resolveCall, reject) => {
|
|
616
645
|
const call: PendingCall = {
|
|
@@ -43,6 +43,7 @@ const HOOK_NAMES = [
|
|
|
43
43
|
"coverLocal",
|
|
44
44
|
"listFiles",
|
|
45
45
|
"imageHashes",
|
|
46
|
+
"onInstall",
|
|
46
47
|
]
|
|
47
48
|
|
|
48
49
|
const API_METHOD_NAMES = [
|
|
@@ -283,7 +284,11 @@ function send(message) {
|
|
|
283
284
|
function approxByteSize(value) {
|
|
284
285
|
if (value instanceof Uint8Array) return value.byteLength
|
|
285
286
|
try {
|
|
286
|
-
return
|
|
287
|
+
// `undefined` (hooks that legitimately return nothing — e.g.
|
|
288
|
+
// `onInstall`) stringifies to `undefined`, not a string: an empty
|
|
289
|
+
// payload must size 0, never throw into the Infinity branch.
|
|
290
|
+
const text = JSON.stringify(value)
|
|
291
|
+
return (typeof text === "string" ? text : "").length * 2
|
|
287
292
|
} catch {
|
|
288
293
|
return Infinity
|
|
289
294
|
}
|