Skip to content

Commit

Permalink
Merge pull request #16 from kamiazya/dybanic-plugin-loader
Browse files Browse the repository at this point in the history
feat: refactor Dynamic Plugin Loader arcitecture
  • Loading branch information
kamiazya authored Sep 11, 2023
2 parents 1ffa19b + 6fffc3d commit 220262d
Show file tree
Hide file tree
Showing 16 changed files with 155 additions and 139 deletions.
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Each package within Pluggable IO follows a consistent naming pattern, ensuring c

- **Plug-n-Play (PnP) Resource Plugins**: `@pluggable-io/{resource}-plugin-{protocol}/pnp`

When imported, these plugins are immediately registered with the default schema in the registry and are ready for use.
When imported, these plugins are immediately loaded with the default schema in the registry and are ready for use.

They inherently import and utilize the corresponding `@pluggable-io/{resource}-plugin-{protocol}` package, ensuring extensibility and consistency.

Expand Down
1 change: 1 addition & 0 deletions config/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'urlpattern-polyfill'
1 change: 1 addition & 0 deletions examples/polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'urlpattern-polyfill'
1 change: 1 addition & 0 deletions examples/storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Create a file and write to it, or read a file and write it to another file.
*/
import './polyfill.js'
import { Storage } from 'pluggable-io'

try {
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"prettier": "2.8.8",
"rimraf": "^5.0.1",
"typescript": "^5.2.2",
"urlpattern-polyfill": "^9.0.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.32.2"
}
Expand Down
62 changes: 4 additions & 58 deletions packages/@pluggable-io/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ This package offers a unified interface for managing various resources.

- **Unified Resource Retrieval**

Construct resource instances from URLs using registered plugins.
Construct resource instances from URLs using loaded plugins.

- **Error Handling**

Handle scenarios where a plugin is already registered or a protocol is not registered.
Handle scenarios where a plugin is already loaded or a protocol is not loaded.

## Installation

Expand All @@ -32,7 +32,7 @@ npm install @pluggable-io/core
Import the main types and interfaces provided by `@pluggable-io/core`.

```typescript
import { Registory, ResourcePlugin, PluginNotRegisteredError, PluginAlreadyRegisteredError } from '@pluggable-io/core'
import { Registory, ResourcePlugin, PluginNotLoadedError, PluginAlreadyLoadedError } from '@pluggable-io/core'
```

### 2. Using the Registory
Expand Down Expand Up @@ -60,66 +60,12 @@ registory.registerPlugin('sample:', {

### 3. Retrieving Resources

Retrieve resource instances using registered plugins.
Retrieve resource instances using loaded plugins.

```typescript
const resource = await registory.from('sample://url/of/resource')
```

## Specification

### Instance Creation

The following flowchart describes the process of creating a resource instance:

```mermaid
graph TD
A[User requests resource from URL]
B{Is plugin for protocol registered?}
C[Throw PluginNotRegisteredError]
D[Use plugin to build resource]
E{Was instance creation successful?}
F[Return resource instance to user]
G[Throw InstanceCreationError]
H{Is plugin available in PLUGIN_PLUG_AND_PLAY?}
I[Register and use the plugin]
A --> B
B -->|No| H
H -->|Yes| I
I --> D
H -->|No| C
B -->|Yes| D
D --> E
E -->|Yes| F
E -->|No| G
```

### 2. Plugin Registration

The following flowchart describes the process of registering a plugin:

```mermaid
graph TD
A[User attempts to register a plugin for a protocol]
B{Is plugin for protocol already registered?}
C[Throw PluginAlreadyRegisteredError]
D[Successfully register the plugin]
A --> B
B -->|Yes| C
B -->|No| D
```

## Notes

### Dynamic Plugin Loading

With the `PLUGIN_PLUG_AND_PLAY` mechanism, you can dynamically load plugins.
This allows you to add or modify plugins during runtime.
To utilize this feature, ensure that the plugin you're trying to load is available in the `PLUGIN_PLUG_AND_PLAY` path and then request a resource using its protocol.
The system will automatically detect, load, and register the plugin for you.

## Contributing

Feedback, bug reports, and pull requests are welcome!
72 changes: 59 additions & 13 deletions packages/@pluggable-io/core/src/models.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,35 +51,81 @@ describe('RegistoryBase', () => {
it('should throw an error if no plugin is loaded', async () => {
await expect(registory.from('not-loaded://')).rejects.toThrow(PluginNotLoadedError)
})
})
describe('addDynamicPluginLoader method', () => {
it('should add a dynamic plugin loader', () => {
const loader = async () => {}
registory.addDynamicPluginLoader('dynamic-load:', loader)
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load:', loader])
})

it('should dynamically resolve the plugin when specifies a pattern in the protocol', async () => {
registory.addDynamicPluginLoader('dynamic-{:name}:', async ({ input, groups: { name } }) => {
registory.load(`${input}:`, {
build: async () => ({ test: name! }),
})
})

await expect(registory.from('dynamic-test1://')).resolves.toStrictEqual({
test: 'test1',
})
expect(registory.plugins.has('dynamic-test1:')).toBe(true)
expect(registory.plugins.size).toBe(1)

await expect(registory.from('dynamic-test2://')).resolves.toStrictEqual({
test: 'test2',
})
expect(registory.plugins.has('dynamic-test2:')).toBe(true)
expect(registory.plugins.size).toBe(2)
})

it('should not throw an error if no plugin is loaded but PLUGIN_PLUG_AND_PLAY is set and the plugin is not loaded', async () => {
registory.PLUGIN_PLUG_AND_PLAY['not-loaded:'] = async () => {
registory.load('not-loaded:', {
it('should add a dynamic plugin loader at the end of the list', () => {
const loader = async () => {}
registory.addDynamicPluginLoader('dynamic-load1:', loader)
registory.addDynamicPluginLoader('dynamic-load2:', loader)
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load1:', loader])
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load2:', loader])
expect(registory.dynamicLoaders[0][0]).toBe('dynamic-load1:')
expect(registory.dynamicLoaders[1][0]).toBe('dynamic-load2:')

registory.addDynamicPluginLoader('dynamic-load3:', loader)
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load1:', loader])
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load2:', loader])
expect(registory.dynamicLoaders).toContainEqual(['dynamic-load3:', loader])
expect(registory.dynamicLoaders[0][0]).toBe('dynamic-load1:')
expect(registory.dynamicLoaders[1][0]).toBe('dynamic-load2:')
expect(registory.dynamicLoaders[2][0]).toBe('dynamic-load3:')
})

it('should load a plugin if dynamic loader is set and the plugin is loaded', async () => {
registory.addDynamicPluginLoader('dynamic-load:', async () => {
registory.load('dynamic-load:', {
build: async () => ({
test: 'test',
}),
})
}
await expect(registory.from('not-loaded://')).resolves.toStrictEqual({
})

await expect(registory.from('dynamic-load://')).resolves.toStrictEqual({
test: 'test',
})
expect(registory.plugins.has('dynamic-load:')).toBe(true)
})

it('shold throw an error if no plugin is loaded but PLUGIN_PLUG_AND_PLAY is set, but failed to execute PLUGIN_PLUG_AND_PLAY', async () => {
registory.PLUGIN_PLUG_AND_PLAY['not-loaded:'] = async () => {
registory.load('not-loaded:', {
it('shold throw an error if no plugin is loaded but dunamic loader is set, but failed to execute dynamic loader', async () => {
registory.addDynamicPluginLoader('dynamic-load:', async () => {
registory.load('dynamic-load:', {
build: async () => ({
test: 'test',
}),
})
throw new Error('test')
}
await expect(registory.from('not-loaded://')).rejects.toThrow(PluginNotLoadedError)
})

await expect(registory.from('dynamic-load://')).rejects.toThrow(PluginNotLoadedError)

// Check plugin is not loaded
expect(registory.plugins.has('not-loaded:')).toBe(false)
// Check if PLUGIN_PLUG_AND_PLAY is removed
expect(registory.PLUGIN_PLUG_AND_PLAY['not-loaded:']).toBe(undefined)
expect(registory.plugins.has('dynamic-load:')).toBe(false)
})
})
})
48 changes: 36 additions & 12 deletions packages/@pluggable-io/core/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
import { PluginAlreadyLoadedError, PluginNotLoadedError, Registory, ResourcePlugin } from './types.js'
/// <reference types="urlpattern-polyfill" />
import {
DynamicPluginLoader,
PluginAlreadyLoadedError,
PluginNotLoadedError,
Registory,
ResourcePlugin,
} from './types.js'

/**
* A registry for resources.
*
* @todo Add caching mechanism for instances
*/
export class RegistoryBase<T> implements Registory<T> {
PLUGIN_PLUG_AND_PLAY: Record<string, () => Promise<any>> = {}
dynamicLoaders: [pattern: string, loader: DynamicPluginLoader][] = []

plugins = new Map<string, ResourcePlugin<T>>()

addDynamicPluginLoader(pattern: string, loader: DynamicPluginLoader) {
this.dynamicLoaders.push([pattern, loader])
}

async dynamicPluginLoad(url: URL) {
for (const [protocol, loader] of this.dynamicLoaders) {
const pattern = new URLPattern({ protocol })
if (pattern.test(url)) {
const input = pattern.exec(url)
await loader(
input?.protocol ?? {
input: url.protocol,
groups: {},
},
)
return
}
}
}

load(protocol: string, plugin: ResourcePlugin<T>) {
if (this.plugins.has(protocol))
throw new PluginAlreadyLoadedError(`Plugin for protocol "${protocol}" already loaded`)
Expand All @@ -28,17 +55,14 @@ export class RegistoryBase<T> implements Registory<T> {
return await this._from(url_)
} catch (error) {
if (error instanceof PluginNotLoadedError) {
if (url_.protocol in this.PLUGIN_PLUG_AND_PLAY) {
try {
await this.PLUGIN_PLUG_AND_PLAY[url_.protocol]()
} catch (error2) {
delete this.PLUGIN_PLUG_AND_PLAY[url_.protocol]
this.plugins.delete(url_.protocol)
new PluginNotLoadedError(`Tried Plug and Play for "${url_.protocol}", but it failed.`, {
cause: new AggregateError([error, error2]),
})
}
try {
await this.dynamicPluginLoad(url_)
return await this._from(url_)
} catch (error2) {
this.plugins.delete(url_.protocol)
throw new PluginNotLoadedError(`Tried dynamic load for "${url_.protocol}", but it failed.`, {
cause: new AggregateError([error, error2]),
})
}
}
throw error
Expand Down
21 changes: 19 additions & 2 deletions packages/@pluggable-io/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,31 @@ export interface ResourcePlugin<T = any> {
build(url: URL): Promise<T>
}

/**
* A loader for a resource plugin
*/
export interface DynamicPluginLoader {
(input: URLPatternComponentResult): Promise<void>
}

/**
* A registry for resources.
*/
export interface Registory<T> {
/**
* @beta This is experimental.
* Add a dynamic plugin loader
*
* @example Add a dynamic plugin loader for `sample+{:encoding}:` scheme
* ```ts
* const registory = new Registory();
* registory.addDynamicPluginLoader('sample+{:encoding}:', async ({ groups: { encoding } }) => {
* await import(`./plugins/sample/${encoding}.pnp.js`);
* })
* ```
* @param pattern The pattern to load for
* @param loader The loader to load
*/
PLUGIN_PLUG_AND_PLAY: Record<string, () => Promise<any>>
addDynamicPluginLoader(pattern: string, loader: DynamicPluginLoader): void

/**
* Load a plugin
Expand Down
13 changes: 2 additions & 11 deletions packages/@pluggable-io/preset-gcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
import { Storage } from '@pluggable-io/storage'

/**
* @beta This API is in beta and may change between minor versions.
*/
export const PLUG_AND_PLAY = Object.freeze({
STORAGE: Object.freeze({
'gs:': () => import('@pluggable-io/storage-plugin-gs/pnp'),
}),
Storage.addDynamicPluginLoader('gs:', async () => {
await import('@pluggable-io/storage-plugin-gs/pnp')
})

for (const [protocol, plugin] of Object.entries(PLUG_AND_PLAY.STORAGE)) {
Storage.PLUGIN_PLUG_AND_PLAY[protocol] = plugin
}
22 changes: 5 additions & 17 deletions packages/@pluggable-io/preset-standard/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import { Storage } from '@pluggable-io/storage'
import { Logger } from '@pluggable-io/logger'

/**
* @beta This API is in beta and may change between minor versions.
*/
export const PLUG_AND_PLAY = Object.freeze({
STORAGE: Object.freeze({
'file:': () => import('@pluggable-io/storage-plugin-file/pnp'),
}),
LOGGER: Object.freeze({
'console:': () => import('@pluggable-io/logger-plugin-console/pnp'),
}),
Storage.addDynamicPluginLoader('file:', async () => {
await import('@pluggable-io/storage-plugin-file/pnp')
})
Logger.addDynamicPluginLoader('console:', async () => {
await import('@pluggable-io/logger-plugin-console/pnp')
})

for (const [protocol, plugin] of Object.entries(PLUG_AND_PLAY.STORAGE)) {
Storage.PLUGIN_PLUG_AND_PLAY[protocol] = plugin
}
for (const [protocol, plugin] of Object.entries(PLUG_AND_PLAY.LOGGER)) {
Logger.PLUGIN_PLUG_AND_PLAY[protocol] = plugin
}
Loading

0 comments on commit 220262d

Please sign in to comment.