Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pkg/inline-mod]: Implements new lazyValue utility #74

Merged
merged 14 commits into from
Apr 19, 2024
6 changes: 6 additions & 0 deletions .changeset/wicked-fireants-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inox-tools/inline-mod": minor
"@inox-tools/aik-mod": patch
---

Implements new `lazyValue` utility
2 changes: 1 addition & 1 deletion docs/src/content/docs/inline-mod/aik-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
47 changes: 47 additions & 0 deletions docs/src/content/docs/inline-mod/lazy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: Lazy values
sidebar:
order: 2
badge:
text: ADVANCED
variant: danger
---

:::danger
Lazy values are an advanced utility, misuse of this feature may cause dealocks, infinite recursions or even serialization of unsafe values.
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
In most cases, [`asyncFactory`](/inline-mod/factory-wrappers#asyncfactory) is a better choice.

Make sure your use case comply with all the [requirements](#requirements) laid out on this page.
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
:::

Sometimes you can only get a valye 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.
Fryuni marked this conversation as resolved.
Show resolved Hide resolved

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 provide the following guarantees:

- The value is set on all possible code paths
- The value is 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)
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
- The value being set does not depend on resolving the serialization of the value (neither directly or indirectly)
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 7 additions & 1 deletion packages/aik-mod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;

Expand Down
62 changes: 59 additions & 3 deletions packages/inline-mod/src/closure/inspectCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ class Inspector {
// a serialized function for each of those, we can emit them a single time.
private readonly simpleFunctions: Entry<'function'>[] = [];

public constructor(private readonly serialize: (o: unknown) => boolean) {}
public constructor(private readonly serialize: (o: unknown) => boolean) { }

public async inspect(
value: unknown,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1145,12 +1151,62 @@ export function magicFactory<T extends Record<any, any>>({
) as unknown as T;
}

function tryExtractMagicFactory(value: any): MagicPlaceholder[typeof factorySymbol] | undefined {
function tryExtractMagicFactory(
value: any
): MagicPlaceholder<any>[typeof factorySymbol] | undefined {
if (factorySymbol in value) {
return value[factorySymbol];
}
}

const lazyValueSymbol = Symbol('inox-tool/lazy-value');

export interface LazyValue<T> {
[lazyValueSymbol]: Promise<T>;
resolved: boolean;
resolve(this: LazyValue<T>, value: T): asserts this is ResolvedLazyValue<T>;
}

export type ResolvedLazyValue<T> = {
[lazyValueSymbol]: Promise<T>;
resolved: true;
resolve: never;
};

const lazyProxyHandler: ProxyHandler<LazyValue<any>> = {
set: () => {
throw new Error('Cannot set properties on a lazy value');
},
};

export function makeLazyValue<T>(): LazyValue<T> {
let resolve: (value: T) => void;
const promise = new Promise<T>((_resolve) => {
resolve = _resolve;
});

const target: LazyValue<T> = {
[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<LazyValue<T>>(target, lazyProxyHandler);
}

function tryExtractLazyValue(value: any): Promise<unknown> | undefined {
if (lazyValueSymbol in value) {
return value[lazyValueSymbol];
}
}

/**
* Cache of global entries
*/
Expand Down Expand Up @@ -1191,7 +1247,7 @@ class GlobalCache {
// these values can be cached once and reused across avery run.

// Add entries to allow proper serialization over generators and iterators.
const emptyGenerator = function* (): any {};
const emptyGenerator = function*(): any { };

this.cache.addUnchecked(Object.getPrototypeOf(emptyGenerator), {
type: 'expr',
Expand Down
5 changes: 5 additions & 0 deletions packages/inline-mod/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<T>(factoryFn: () => T): T {
return magicFactory({
Expand Down
86 changes: 0 additions & 86 deletions packages/inline-mod/tests/functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`);
});
99 changes: 99 additions & 0 deletions packages/inline-mod/tests/specialValues.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

setTimeout(() => {
value.resolve('foo');
}, 500);

const modInfo = await inspectInlineMod({
defaultExport: value,
});

expect(modInfo.text).toEqualIgnoringWhitespace(`
export default "foo";
`);
});
5 changes: 4 additions & 1 deletion packages/inline-mod/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.base.json"
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext"]
}
}
Loading