Skip to content

Commit 89bda73

Browse files
committed
feat: prompt to install storage driver dependencies
1 parent c192a4c commit 89bda73

8 files changed

Lines changed: 187 additions & 118 deletions

File tree

docs/1.docs/8.storage.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ Then, you can use the redis storage using the `useStorage("redis")` function.
101101
You can find the driver list on [unstorage documentation](https://unstorage.unjs.io/) with their configuration.
102102
::
103103

104+
### Driver dependencies
105+
106+
Some drivers rely on a third-party library (for example, `redis` requires [`ioredis`](https://www.npmjs.com/package/ioredis)).
107+
108+
Nitro detects the libraries required by the mounted drivers and prompts to install the missing ones (installed automatically in CI). Installed libraries are then explicitly passed to the driver via its `lib` option, so that bundlers can statically resolve them.
109+
104110
### Development storage
105111

106112
You can use the `devStorage` option to override storage configuration during development and prerendering.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"rou3": "^0.9.2",
8383
"srvx": "^0.12.7",
8484
"unenv": "^2.0.0-rc.24",
85-
"unstorage": "^2.0.0-alpha.7"
85+
"unstorage": "^2.0.0-alpha.8"
8686
},
8787
"devDependencies": {
8888
"@apphosting/common": "^0.0.9",

pnpm-lock.yaml

Lines changed: 5 additions & 82 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/build/virtual/storage.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,13 @@
11
import { genImport, genSafeVariableName } from "knitwork";
22
import type { Nitro } from "nitro/types";
3-
import { builtinDrivers } from "unstorage";
3+
import { isDepInstalled } from "../../utils/dep.ts";
4+
import { isLibOption, resolveDriverDeps, resolveStorageMounts } from "../../utils/storage.ts";
45

56
export default function storage(nitro: Nitro) {
67
return {
78
id: "#nitro/virtual/storage",
89
template: () => {
9-
const mounts: { path: string; driver: string; opts: object }[] = [];
10-
11-
const isDevOrPrerender = nitro.options.dev || nitro.options.preset === "nitro-prerender";
12-
const storageMounts = isDevOrPrerender
13-
? { ...nitro.options.storage, ...nitro.options.devStorage }
14-
: nitro.options.storage;
15-
16-
for (const path in storageMounts) {
17-
const { driver: driverName, ...driverOpts } = storageMounts[path];
18-
mounts.push({
19-
path,
20-
driver: builtinDrivers[driverName as keyof typeof builtinDrivers] || driverName,
21-
opts: driverOpts,
22-
});
23-
}
10+
const mounts = resolveStorageMounts(nitro.options);
2411

2512
const driverImports = [...new Set(mounts.map((m) => m.driver))];
2613

@@ -41,7 +28,7 @@ export function initStorage() {
4128
${mounts
4229
.map(
4330
(m) =>
44-
`storage.mount('${m.path}', ${genSafeVariableName(m.driver)}(${JSON.stringify(m.opts)}))`
31+
`storage.mount('${m.path}', ${genSafeVariableName(m.driver)}(${genDriverOptions(nitro, m)}))`
4532
)
4633
.join("\n")}
4734
return ${tracingEnabled ? "withTracing(storage)" : "storage"}
@@ -50,3 +37,21 @@ export function initStorage() {
5037
},
5138
};
5239
}
40+
41+
/**
42+
* Explicitly provide third-party libraries used by the driver via the `lib` option
43+
* so that they are statically analyzable by the bundler.
44+
*/
45+
function genDriverOptions(nitro: Nitro, mount: ReturnType<typeof resolveStorageMounts>[number]) {
46+
const libs = resolveDriverDeps(mount.name)
47+
.filter(
48+
(dep) =>
49+
isLibOption(dep.option) &&
50+
mount.options[dep.option] === undefined &&
51+
isDepInstalled(dep.name, nitro.options.rootDir)
52+
)
53+
.map((dep) => `${dep.option}: () => import(${JSON.stringify(dep.name)})`);
54+
55+
const options = JSON.stringify(mount.options);
56+
return libs.length > 0 ? `{ ...${options}, ${libs.join(", ")} }` : options;
57+
}

src/config/resolvers/storage.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1+
import consola from "consola";
12
import type { NitroOptions } from "nitro/types";
3+
import { ensureDep } from "../../utils/dep.ts";
4+
import { resolveDriverDeps, resolveStorageMounts } from "../../utils/storage.ts";
25

36
export async function resolveStorageOptions(options: NitroOptions) {
4-
//
7+
// Storage drivers lazily import their third-party dependencies.
8+
// Make sure the ones required by the configured mounts are installed.
9+
const deps = new Map<string, { version?: string; drivers: Set<string> }>();
10+
for (const mount of resolveStorageMounts(options)) {
11+
for (const dep of resolveDriverDeps(mount.name)) {
12+
if (dep.optional || mount.options[dep.option] !== undefined) {
13+
continue; // Not required or explicitly provided by the user
14+
}
15+
const entry = deps.get(dep.name) || { version: dep.version, drivers: new Set() };
16+
entry.drivers.add(mount.name);
17+
deps.set(dep.name, entry);
18+
}
19+
}
20+
21+
for (const [name, { version, drivers }] of deps) {
22+
const reason = `the ${[...drivers].map((d) => `\`${d}\``).join(", ")} storage driver${drivers.size > 1 ? "s" : ""}`;
23+
const resolved = await ensureDep({
24+
id: name,
25+
version,
26+
dir: options.rootDir,
27+
reason,
28+
dev: false,
29+
});
30+
if (!resolved) {
31+
consola.warn(`\`${name}\` is not installed. It is required for ${reason}.`);
32+
}
33+
}
534
}

src/utils/dep.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,33 @@ import { consola } from "consola";
22
import { resolveModulePath } from "exsolve";
33
import { isCI, isTest } from "std-env";
44

5-
export async function importDep<T>(
6-
opts: {
7-
id: string;
8-
dir: string;
9-
reason: string;
10-
},
11-
_retry?: boolean
12-
): Promise<T> {
5+
export interface DepOptions {
6+
/** Package name to resolve and install. */
7+
id: string;
8+
/** Directory to resolve from and install into. */
9+
dir: string;
10+
/** Human readable reason, used in prompts and errors. */
11+
reason: string;
12+
/** Version range used when installing (ignored if it is not a simple range). */
13+
version?: string;
14+
/** Install as a dev dependency. (default: `true`) */
15+
dev?: boolean;
16+
}
17+
18+
/**
19+
* Ensure a dependency is installed, prompting to install it if missing.
20+
*
21+
* Resolves to the module path or `undefined` if it is (still) not available.
22+
*/
23+
export async function ensureDep(opts: DepOptions, _retry?: boolean): Promise<string | undefined> {
1324
const resolved = resolveModulePath(opts.id, {
1425
from: [opts.dir, import.meta.url],
1526
cache: _retry ? false : true,
1627
try: true,
1728
});
1829

1930
if (resolved) {
20-
return (await import(resolved)) as Promise<T>;
31+
return resolved;
2132
}
2233

2334
let shouldInstall: boolean | undefined;
@@ -36,16 +47,29 @@ export async function importDep<T>(
3647
}
3748

3849
if (!shouldInstall) {
39-
throw new Error(
40-
`\`${opts.id}\` is not installed. Please add it to your dependencies for ${opts.reason}.`
41-
);
50+
return undefined;
4251
}
4352

4453
const start = Date.now();
4554
consola.start(`Installing \`${opts.id}\` in \`${opts.dir}\`...`);
46-
const { addDevDependency } = await import("nypm");
47-
await addDevDependency(opts.id, { cwd: opts.dir });
55+
const { addDependency, addDevDependency } = await import("nypm");
56+
const spec = opts.version && !opts.version.includes(" ") ? `${opts.id}@${opts.version}` : opts.id;
57+
await (opts.dev === false ? addDependency : addDevDependency)(spec, { cwd: opts.dir });
4858
consola.success(`Installed \`${opts.id}\` in ${opts.dir} (${Date.now() - start}ms).`);
4959

50-
return importDep<T>(opts, true);
60+
return ensureDep(opts, true);
61+
}
62+
63+
export async function importDep<T>(opts: DepOptions): Promise<T> {
64+
const resolved = await ensureDep(opts);
65+
if (!resolved) {
66+
throw new Error(
67+
`\`${opts.id}\` is not installed. Please add it to your dependencies for ${opts.reason}.`
68+
);
69+
}
70+
return (await import(resolved)) as T;
71+
}
72+
73+
export function isDepInstalled(id: string, dir: string): boolean {
74+
return !!resolveModulePath(id, { from: [dir, import.meta.url], try: true });
5175
}

src/utils/storage.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { NitroOptions } from "nitro/types";
2+
import { builtinDriverDependencies, builtinDrivers } from "unstorage";
3+
import type { BuiltinDriverName } from "unstorage";
4+
5+
export interface StorageMount {
6+
/** Mount point path. */
7+
path: string;
8+
/** Driver name as configured by the user. */
9+
name: string;
10+
/** Module id to import the driver from. */
11+
driver: string;
12+
/** Driver options. */
13+
options: Record<string, any>;
14+
}
15+
16+
export interface StorageDriverDep {
17+
/** Driver option the library can be provided with (e.g. `lib`). */
18+
option: string;
19+
/** Package name. */
20+
name: string;
21+
/** Supported version range. */
22+
version?: string;
23+
/** Only required for some of the driver features. */
24+
optional?: boolean;
25+
}
26+
27+
/** Resolve storage mounts that will be used for the current build. */
28+
export function resolveStorageMounts(options: NitroOptions): StorageMount[] {
29+
const isDevOrPrerender = options.dev || options.preset === "nitro-prerender";
30+
const mounts = isDevOrPrerender ? { ...options.storage, ...options.devStorage } : options.storage;
31+
return Object.entries(mounts).map(([path, { driver: name, ...driverOpts }]) => ({
32+
path,
33+
name,
34+
driver: builtinDrivers[name as BuiltinDriverName] || name,
35+
options: driverOpts,
36+
}));
37+
}
38+
39+
/**
40+
* Third-party dependencies of a builtin driver.
41+
*
42+
* Since `unstorage` v2, they are not declared as optional peer dependencies anymore.
43+
*/
44+
export function resolveDriverDeps(name: string): StorageDriverDep[] {
45+
const deps = builtinDriverDependencies[name as BuiltinDriverName];
46+
return Object.entries(deps || {}).map(([option, dep]) => ({ option, ...dep }));
47+
}
48+
49+
/** Driver options accepting a library import (`lib`, `identityLib`, ...). */
50+
export function isLibOption(option: string): boolean {
51+
return option === "lib" || option.endsWith("Lib");
52+
}

0 commit comments

Comments
 (0)