Skip to content

Commit

Permalink
Add runtime definition for content sources
Browse files Browse the repository at this point in the history
  • Loading branch information
SamyPesse committed Feb 3, 2025
1 parent db4f6ad commit 3603ba1
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 50 deletions.
11 changes: 11 additions & 0 deletions packages/runtime/src/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type PlainObjectValue =
| number
| string
| boolean
| PlainObject
| undefined
| null
| PlainObjectValue[];
export type PlainObject = {
[key: string]: PlainObjectValue;
};
90 changes: 45 additions & 45 deletions packages/runtime/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import {
ContentKitBlock,
UIRenderEvent,
ContentKitRenderOutput,
ContentKitContext,
ContentKitDefaultAction,
ContentKitRenderOutput,
UIRenderEvent,
} from '@gitbook/api';

import { RuntimeCallback, RuntimeContext } from './context';

type PlainObjectValue =
| number
| string
| boolean
| PlainObject
| undefined
| null
| PlainObjectValue[];
type PlainObject = {
[key: string]: PlainObjectValue;
import { RuntimeCallback, RuntimeEnvironment, RuntimeContext } from './context';
import { PlainObject } from './common';

/**
* Props for an installation configuration component.
*/
export type InstallationConfigurationProps<Env extends RuntimeEnvironment> = {
installation: {
configuration: Env extends RuntimeEnvironment<infer Config, any> ? Config : never;
};
};

/**
* Props for an installation configuration component.
*/
export type SpaceInstallationConfigurationProps<Env extends RuntimeEnvironment> =
InstallationConfigurationProps<Env> & {
spaceInstallation: {
configuration?: Env extends RuntimeEnvironment<any, infer Config> ? Config : never;
};
};

/**
* Cache configuration for the output of a component.
*/
export interface ComponentRenderCache {
maxAge: number;
}

/**
* Instance of a component, passed to the `render` and `action` function.
*/
export interface ComponentInstance<Props extends PlainObject, State extends PlainObject> {
props: Props;
state: State;
Expand All @@ -40,6 +54,9 @@ export interface ComponentInstance<Props extends PlainObject, State extends Plai
dynamicState<Key extends keyof State>(key: Key): { $state: Key };
}

/**
* Definition of a component. Exported from `createComponent` and should be passed to `components` in the integration.
*/
export interface ComponentDefinition<Context extends RuntimeContext = RuntimeContext> {
componentId: string;
render: RuntimeCallback<[UIRenderEvent], Promise<Response>, Context>;
Expand Down Expand Up @@ -75,11 +92,7 @@ export function createComponent<
*/
action?: RuntimeCallback<
[ComponentInstance<Props, State>, ComponentAction<Action>],
Promise<
| { type?: 'element'; props?: Props; state?: State }
| { type: 'complete'; returnValue?: PlainObject }
| undefined
>,
Promise<{ props?: Props; state?: State } | undefined>,
Context
>;

Expand Down Expand Up @@ -116,42 +129,29 @@ export function createComponent<
dynamicState: (key) => ({ $state: key }),
};

const wrapResponse = (output: ContentKitRenderOutput) => {
return new Response(JSON.stringify(output), {
headers: {
'Content-Type': 'application/json',
...(cache
? {
// @ts-ignore - I'm not sure how to fix this one with TS
'Cache-Control': `max-age=${cache.maxAge}`,
}
: {}),
},
});
};

if (action && component.action) {
const actionResult = await component.action(instance, action, context);

// If the action is complete, return the result directly. No need to render the component.
if (actionResult?.type === 'complete') {
return wrapResponse(actionResult);
}

instance = { ...instance, ...actionResult };
instance = { ...instance, ...(await component.action(instance, action, context)) };
}

const element = await component.render(instance, context);

const output: ContentKitRenderOutput = {
// for backward compatibility always default to 'element'
type: 'element',
state: instance.state,
props: instance.props,
element,
};

return wrapResponse(output);
return new Response(JSON.stringify(output), {
headers: {
'Content-Type': 'application/json',
...(cache
? {
// @ts-ignore - I'm not sure how to fix this one with TS
'Cache-Control': `max-age=${cache.maxAge}`,
}
: {}),
},
});
},
};
}
}
84 changes: 84 additions & 0 deletions packages/runtime/src/contentSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
ContentComputeDocumentEvent,
ContentComputeRevisionEventResponse,
ContentComputeRevisionEvent,
Document,
} from '@gitbook/api';
import { PlainObject } from './common';
import { RuntimeCallback, RuntimeContext } from './context';

export interface ContentSourceDefinition<Context extends RuntimeContext = RuntimeContext> {
sourceId: string;
compute: RuntimeCallback<
[ContentComputeRevisionEvent | ContentComputeDocumentEvent],
Promise<Response>,
Context
>;
}

/**
* Create a content source. The result should be bind to the integration using `contentSources`.
*/
export function createContentSource<
Props extends PlainObject = {},
Context extends RuntimeContext = RuntimeContext,
>(source: {
/**
* ID of the source, referenced in the YAML file.
*/
sourceId: string;

/**
* Callback to generate the pages.
*/
getRevision: RuntimeCallback<
[
{
props: Props;
},
],
Promise<ContentComputeRevisionEventResponse>,
Context
>;

/**
* Callback to generate the document of a page.
*/
getPageDocument: RuntimeCallback<
[
{
props: Props;
},
],
Promise<Document | void>,
Context
>;
}): ContentSourceDefinition<Context> {
return {
sourceId: source.sourceId,
compute: async (event, context) => {
const output =
event.type === 'content_compute_revision'
? await source.getRevision(
{
props: event.props as Props,
},
context,
)
: {
document: await source.getPageDocument(
{
props: event.props as Props,
},
context,
),
};

return new Response(JSON.stringify(output), {
headers: {
'content-type': 'application/json',
},
});
},
};
}
27 changes: 22 additions & 5 deletions packages/runtime/src/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from './events';
import { Logger } from './logger';
import { ExposableError } from './errors';
import { ContentSourceDefinition } from './contentSources';

const logger = Logger('integrations');

Expand Down Expand Up @@ -39,6 +40,11 @@ interface IntegrationRuntimeDefinition<Context extends RuntimeContext = RuntimeC
* Components to expose in the integration.
*/
components?: Array<ComponentDefinition<Context>>;

/**
* Content sources to expose in the integration.
*/
contentSources?: Array<ContentSourceDefinition<Context>>;
}

/**
Expand All @@ -47,7 +53,7 @@ interface IntegrationRuntimeDefinition<Context extends RuntimeContext = RuntimeC
export function createIntegration<Context extends RuntimeContext = RuntimeContext>(
definition: IntegrationRuntimeDefinition<Context>,
) {
const { events = {}, components = [] } = definition;
const { events = {}, components = [], contentSources = [] } = definition;

/**
* Handle a fetch event sent by the integration dispatcher.
Expand Down Expand Up @@ -161,6 +167,17 @@ export function createIntegration<Context extends RuntimeContext = RuntimeContex
return await component.render(event, context);
}

case 'content_compute_revision':
case 'content_compute_document': {
const contentSource = contentSources.find((c) => c.sourceId === event.sourceId);

if (!contentSource) {
throw new ExposableError(`Content source ${event.sourceId} not found`, 404);
}

return await contentSource.compute(event, context);
}

default: {
const cb = events[event.type];

Expand All @@ -178,10 +195,10 @@ export function createIntegration<Context extends RuntimeContext = RuntimeContex
return new Response('OK', { status: 200 });
}

logger.info(`integration does not handle ${event.type} events`);
return new Response(`Integration does not handle ${event.type} events`, {
status: 200,
});
throw new ExposableError(
`Integration does not handle "${event.type}" events`,
406,
);
}
}
} catch (err) {
Expand Down

0 comments on commit 3603ba1

Please sign in to comment.