diff --git a/.changeset/wicked-fireants-switch.md b/.changeset/wicked-fireants-switch.md new file mode 100644 index 00000000..f770a32f --- /dev/null +++ b/.changeset/wicked-fireants-switch.md @@ -0,0 +1,6 @@ +--- +"@inox-tools/inline-mod": minor +"@inox-tools/aik-mod": patch +--- + +Implements new `lazyValue` utility diff --git a/docs/src/content/docs/inline-mod/aik-plugin.md b/docs/src/content/docs/inline-mod/aik-plugin.md index 95f9e17d..164462ea 100644 --- a/docs/src/content/docs/inline-mod/aik-plugin.md +++ b/docs/src/content/docs/inline-mod/aik-plugin.md @@ -35,7 +35,7 @@ export default defineIntegration({ ## API -The plugin exposes multiple entrypoints, all of them accept normal values and [factory wrappers](/inline-mod/factory-wrappers) as values to be included in the virtual modules. +The plugin exposes multiple entrypoints, all of them accept normal values, [factory wrappers](/inline-mod/factory-wrappers) and [lazy values](/inline-mod/lazy) as values to be included in the virtual modules. ### `inlineModule` diff --git a/docs/src/content/docs/inline-mod/lazy.md b/docs/src/content/docs/inline-mod/lazy.md new file mode 100644 index 00000000..bf65d1bf --- /dev/null +++ b/docs/src/content/docs/inline-mod/lazy.md @@ -0,0 +1,201 @@ +--- +title: Lazy values +sidebar: + order: 2 + badge: + text: ADVANCED + variant: danger +--- + +:::danger +Lazy values are an advanced utility, misuse of this feature may cause deadlocks, infinite recursions or even serialization of unsafe values. +In most cases, [`asyncFactory`](/inline-mod/factory-wrappers#asyncfactory) is a better choice. + +Make sure your use case complies with all the [requirements](#requirements) laid out on this page. +::: + +Sometimes you can only get a value after the module where it should be serialized to is created. When using the [AIK Plugin](/inline-mod/aik-plugin), for example, you can only define an inline module during the `astro:config:setup` hook, but you might want to serialize a value from other hooks. + +For such use cases, you can use the `lazyValue` utility to create a placeholder value that you can set later: + +```ts +import { lazyValue } from '@inox-tools/inline-mod'; +import { defineModule } from '@inox-tools/inline-mod/vite'; + +const value = lazyValue(); + +defineModule('virtual:your-plugin/config', { + constExports: { value }, +}); + +value.set('hello'); +``` + +## Under the hood + +Internally this utility will park the module inspection and serialization in Node's event loop such that it can be resumed once, and only once, the value of the lazy value is set. +This allows for as much of the inspection/serialization work to be done preemptively, and not wait for the value to be set. + +During the module resolution, anything awaiting on the `load` event of the virtual module will also be parked until the value is set. + +## Requirements + +When using `lazyValue` you must guarantee some invariants about the code using it. Failing to do so will cause serious problems in your project, ranging from deadlocks to unsafe code execution on your server. + +### Set value on all possible code paths + +There must not be any possible code path where a lazy value is not set. + +#### Example + +If the API call to the remote config fails or if the response is not valid JSON, the value will not be set. + +```ts +import { lazyValue } from '@inox-tools/inline-mod'; + +const remoteConfig = lazyValue(); + +fetch('https://your.api.com/config') + .then((res) => res.json()) + .then((config) => remoteConfig.set(config)); +``` + +Do this instead: + +```ts ins={9-13} +import { lazyValue } from '@inox-tools/inline-mod'; + +const remoteConfig = lazyValue(); + +fetch('https://your.api.com/config') + .then((res) => res.json()) + .then( + (config) => remoteConfig.set(config), + (error) => { + // Set a default value if the API call fails that is available as the error is propagated + remoteConfig.set(null); + throw error; + } + ); +``` + +### Set value before bundling completes + +The value must be set before Vite's bundling is expected to complete (and here we say expected because if you don't set the value, Vite will never complete the bundling). + +#### Example + +In the following code, `pagesData` is set on an Astro hook that runs before Vite's bundling, allowing the pages data to be serialized. + +On the other hand, `pagesDataLate` is being set on an Astro hook that runs _after_ Vite's bundling, this is not allowed. Using the `pagesDataLate` value will lead to problems. + +```ts ins={"This runs before Vite's bundling, correct": 16-17} del={"This runs after Vite's bundling, incorrect": 20-21} +import { defineIntegration, withPlugins } from 'astro-integration-kit'; +import { lazyValue } from '@inox-tools/inline-mod'; +import aikMod from '@inox-tools/aik-mod'; + +export default defineIntegration({ + name: 'my-integration', + setup: ({ name }) => { + const pagesData = lazyValue(); + const pagesDataLate = lazyValue(); + + return withPlugins({ + name, + plugins: [aikMod], + hooks: { + 'astro:build:setup': ({ pages }) => { +. + pagesData.set(pages); + }, + 'astro:build:done': ({ pages }) => { +. + pagesDataLate.set(pages); + }, + }, + }); + }, +}); +``` + +### No self dependency + +The value must not depend on it's own serialization, neither directly on it's value, or indirectly due to control flow. + +#### Example 1: Value is set to it's own serialization + +```ts del={"The value is set to the result of serializing itself": 24-25} +import inlineMod from '@inox-tools/inline-mod/vite'; +import { lazyValue } from '@inox-tools/inline-mod'; + +const value = lazyValue(); + +const moduleId = inlineMod({ + constExports: { + value, + }, +}); + +export default () => { + return [ + inlineMod(), + { + name: 'your-plugin', + resolveId(id) { + if (id === moduleId) { + return '\x00' + moduleId; + } + }, + async load(id) { + if (id !== '\x00' + moduleId) return; + + value.set(await this.load({ id })); + + return result; + }, + }, + ]; +}; +``` + +#### Example 2: Value is not set unless serialized + +In this case the value doesn't seem to depend on itself, but it does. It is only set after it is serialized, but it can't be serialized until it's set, so even though the value being set is independent of the serialization the action of setting the value is not. + +```ts {"Serializes the module, which includes the value": 25-26} del={"But value is only set after the serialization completes, which can't happen": 28-29} +import inlineMod from '@inox-tools/inline-mod/vite'; +import { lazyValue } from '@inox-tools/inline-mod'; + +const value = lazyValue(); + +const moduleId = inlineMod({ + constExports: { + value, + }, +}); + +export default () => { + return [ + inlineMod(), + { + name: 'your-plugin', + resolveId(id) { + if (id === moduleId) { + return '\x00' + moduleId; + } + }, + async load(id) { + if (id !== '\x00' + moduleId) return; + +. + const result = await this.load({ id }); + +. + value.set('foo'); + + return result; + }, + }, + ]; +}; +``` diff --git a/packages/aik-mod/src/index.ts b/packages/aik-mod/src/index.ts index 36bf108e..b19f3b0f 100644 --- a/packages/aik-mod/src/index.ts +++ b/packages/aik-mod/src/index.ts @@ -8,7 +8,13 @@ import { definePlugin } from 'astro-integration-kit'; import { AstroError } from 'astro/errors'; import type { PluginOption } from 'vite'; -export { asyncFactory, factory } from '@inox-tools/inline-mod'; +export { + asyncFactory, + factory, + lazyValue, + type LazyValue, + type ResolvedLazyValue, +} from '@inox-tools/inline-mod'; type HookParams = HookParameters<'astro:config:setup'>; diff --git a/packages/inline-mod/src/closure/inspectCode.ts b/packages/inline-mod/src/closure/inspectCode.ts index 2fd92c1e..211ecf7f 100644 --- a/packages/inline-mod/src/closure/inspectCode.ts +++ b/packages/inline-mod/src/closure/inspectCode.ts @@ -284,6 +284,12 @@ class Inspector { }; } + const lazyValue = tryExtractLazyValue(value); + if (lazyValue) { + const resolvedValue = await lazyValue; + return this.inspect(resolvedValue); + } + if (this.doNotCapture(value)) { log('Value should skip capture'); return Entry.json(); @@ -1145,12 +1151,62 @@ export function magicFactory>({ ) as unknown as T; } -function tryExtractMagicFactory(value: any): MagicPlaceholder[typeof factorySymbol] | undefined { +function tryExtractMagicFactory( + value: any +): MagicPlaceholder[typeof factorySymbol] | undefined { if (factorySymbol in value) { return value[factorySymbol]; } } +const lazyValueSymbol = Symbol('inox-tool/lazy-value'); + +export interface LazyValue { + [lazyValueSymbol]: Promise; + resolved: boolean; + resolve(this: LazyValue, value: T): asserts this is ResolvedLazyValue; +} + +export type ResolvedLazyValue = { + [lazyValueSymbol]: Promise; + resolved: true; + resolve: never; +}; + +const lazyProxyHandler: ProxyHandler> = { + set: () => { + throw new Error('Cannot set properties on a lazy value'); + }, +}; + +export function makeLazyValue(): LazyValue { + let resolve: (value: T) => void; + const promise = new Promise((_resolve) => { + resolve = _resolve; + }); + + const target: LazyValue = { + [lazyValueSymbol]: promise, + resolved: false, + resolve(value) { + if (target.resolved) { + throw new Error('A lazy value can only be resolved once.'); + } + + resolve(value); + target.resolved = true; + }, + }; + + return new Proxy>(target, lazyProxyHandler); +} + +function tryExtractLazyValue(value: any): Promise | undefined { + if (lazyValueSymbol in value) { + return value[lazyValueSymbol]; + } +} + /** * Cache of global entries */ diff --git a/packages/inline-mod/src/index.ts b/packages/inline-mod/src/index.ts index 566079bc..212217bf 100644 --- a/packages/inline-mod/src/index.ts +++ b/packages/inline-mod/src/index.ts @@ -1,4 +1,9 @@ import { magicFactory } from './closure/inspectCode.js'; +export { + type LazyValue, + type ResolvedLazyValue, + makeLazyValue as lazyValue, +} from './closure/inspectCode.js'; export function factory(factoryFn: () => T): T { return magicFactory({ diff --git a/packages/inline-mod/tests/functions.test.ts b/packages/inline-mod/tests/functions.test.ts index 4c1a3ed2..2f65cf01 100644 --- a/packages/inline-mod/tests/functions.test.ts +++ b/packages/inline-mod/tests/functions.test.ts @@ -82,89 +82,3 @@ test('recurring function', async () => { export default __f; `); }); - -test('factory values', async () => { - let callCount = 0; - - const factoryValue = magicFactory({ - isAsync: false, - fn: () => { - callCount++; - - return { - value: 'foo', - }; - }, - }); - - const modInfo = await inspectInlineMod({ - defaultExport: factoryValue, - }); - - expect(modInfo.text).toEqualIgnoringWhitespace(` - function __f0() { - return (function() { - const callCount = 0; - return () => { - callCount++; - return { - value: "foo" - }; - }; - }).apply(undefined, undefined).apply(this, arguments); - } - - const __defaultExport = __f0(); - - export default __defaultExport; - `); - - expect(callCount).toBe(0); - - expect(factoryValue.value).toEqual('foo'); - - expect(callCount).toBe(1); - - factoryValue.value = 'bar'; - expect(factoryValue.value).toEqual('bar'); - - expect(callCount).toBe(1); -}); - -test('async factory values', async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used to test serialization - let callCount = 0; - - const factoryValue = magicFactory({ - isAsync: true, - fn: () => { - callCount++; - - return Promise.resolve({ - value: 'foo', - }); - }, - }); - - const modInfo = await inspectInlineMod({ - defaultExport: factoryValue, - }); - - expect(modInfo.text).toEqualIgnoringWhitespace(` - function __f0() { - return (function() { - const callCount = 0; - return () => { - callCount++; - return Promise.resolve({ - value: "foo" - }); - }; - }).apply(undefined, undefined).apply(this, arguments); - } - - const __defaultExport = await __f0(); - - export default __defaultExport; - `); -}); diff --git a/packages/inline-mod/tests/specialValues.test.ts b/packages/inline-mod/tests/specialValues.test.ts new file mode 100644 index 00000000..59eda847 --- /dev/null +++ b/packages/inline-mod/tests/specialValues.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from 'vitest'; +import { inspectInlineMod } from '../src/inlining.js'; +import { asyncFactory, factory, lazyValue } from '../src/index.js'; + +test('factory values', async () => { + let callCount = 0; + + const factoryValue = factory(() => { + callCount++; + + return { + value: 'foo', + }; + }); + + const modInfo = await inspectInlineMod({ + defaultExport: factoryValue, + }); + + expect(modInfo.text).toEqualIgnoringWhitespace(` + function __f0() { + return (function() { + const callCount = 0; + return () => { + callCount++; + return { + value: "foo" + }; + }; + }).apply(undefined, undefined).apply(this, arguments); + } + + const __defaultExport = __f0(); + + export default __defaultExport; + `); + + expect(callCount).toBe(0); + + expect(factoryValue.value).toEqual('foo'); + + expect(callCount).toBe(1); + + factoryValue.value = 'bar'; + expect(factoryValue.value).toEqual('bar'); + + expect(callCount).toBe(1); +}); + +test('async factory values', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used to test serialization + let callCount = 0; + + const factoryValue = asyncFactory(() => { + callCount++; + + return Promise.resolve({ + value: 'foo', + }); + }); + + const modInfo = await inspectInlineMod({ + defaultExport: factoryValue, + }); + + expect(modInfo.text).toEqualIgnoringWhitespace(` + function __f0() { + return (function() { + const callCount = 0; + return () => { + callCount++; + return Promise.resolve({ + value: "foo" + }); + }; + }).apply(undefined, undefined).apply(this, arguments); + } + + const __defaultExport = await __f0(); + + export default __defaultExport; + `); +}); + +test('lazy values', async () => { + const value = lazyValue(); + + setTimeout(() => { + value.resolve('foo'); + }, 500); + + const modInfo = await inspectInlineMod({ + defaultExport: value, + }); + + expect(modInfo.text).toEqualIgnoringWhitespace(` + export default "foo"; + `); +}); diff --git a/packages/inline-mod/tsconfig.json b/packages/inline-mod/tsconfig.json index bfc6df51..b659d426 100644 --- a/packages/inline-mod/tsconfig.json +++ b/packages/inline-mod/tsconfig.json @@ -1,4 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext"] + } }