@skillcdn/console 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/SKILLCDN.md +7 -5
- package/dist/api.d.ts +5 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +569 -48
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +34 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +283 -7
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +44 -7
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/plugin-check.d.ts +26 -0
- package/dist/cli/plugin-check.d.ts.map +1 -0
- package/dist/cli/plugin-check.js +396 -0
- package/dist/cli/plugin-check.js.map +1 -0
- package/dist/cli/plugin-cli.d.ts +20 -0
- package/dist/cli/plugin-cli.d.ts.map +1 -0
- package/dist/cli/plugin-cli.js +189 -0
- package/dist/cli/plugin-cli.js.map +1 -0
- package/dist/cli/plugin-init.d.ts +37 -0
- package/dist/cli/plugin-init.d.ts.map +1 -0
- package/dist/cli/plugin-init.js +206 -0
- package/dist/cli/plugin-init.js.map +1 -0
- package/dist/cli/plugin-main.d.ts +3 -0
- package/dist/cli/plugin-main.d.ts.map +1 -0
- package/dist/cli/plugin-main.js +28 -0
- package/dist/cli/plugin-main.js.map +1 -0
- package/dist/client.d.ts +100 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +79 -7
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +1 -1
- package/dist/compat.js +2 -2
- package/dist/components/board.d.ts +8 -0
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +59 -15
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +1 -1
- package/dist/components/comments.js.map +1 -1
- package/dist/components/decision-form.d.ts +2 -1
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +36 -4
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +29 -3
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +33 -1
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +2 -0
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +2 -2
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/plugin-frame.d.ts +52 -0
- package/dist/components/plugin-frame.d.ts.map +1 -0
- package/dist/components/plugin-frame.js +314 -0
- package/dist/components/plugin-frame.js.map +1 -0
- package/dist/components/plugin-settings.d.ts +38 -0
- package/dist/components/plugin-settings.d.ts.map +1 -0
- package/dist/components/plugin-settings.js +348 -0
- package/dist/components/plugin-settings.js.map +1 -0
- package/dist/components/shell.d.ts +2 -0
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +26 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-view.d.ts +2 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/workspace.d.ts +7 -1
- package/dist/components/workspace.d.ts.map +1 -1
- package/dist/components/workspace.js +1 -1
- package/dist/components/workspace.js.map +1 -1
- package/dist/console.css +330 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +152 -41
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +9 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +45 -9
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +155 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +166 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +150 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/plugin/args.d.ts +148 -0
- package/dist/plugin/args.d.ts.map +1 -0
- package/dist/plugin/args.js +369 -0
- package/dist/plugin/args.js.map +1 -0
- package/dist/plugin/bridge.d.ts +146 -0
- package/dist/plugin/bridge.d.ts.map +1 -0
- package/dist/plugin/bridge.js +125 -0
- package/dist/plugin/bridge.js.map +1 -0
- package/dist/plugin/capabilities.d.ts +20 -0
- package/dist/plugin/capabilities.d.ts.map +1 -0
- package/dist/plugin/capabilities.js +133 -0
- package/dist/plugin/capabilities.js.map +1 -0
- package/dist/plugin/context.d.ts +380 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +2 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/define.d.ts +208 -0
- package/dist/plugin/define.d.ts.map +1 -0
- package/dist/plugin/define.js +482 -0
- package/dist/plugin/define.js.map +1 -0
- package/dist/plugin/definition-error.d.ts +11 -0
- package/dist/plugin/definition-error.d.ts.map +1 -0
- package/dist/plugin/definition-error.js +17 -0
- package/dist/plugin/definition-error.js.map +1 -0
- package/dist/plugin/limits.d.ts +67 -0
- package/dist/plugin/limits.d.ts.map +1 -0
- package/dist/plugin/limits.js +70 -0
- package/dist/plugin/limits.js.map +1 -0
- package/dist/plugin/load-worker.d.ts +7 -0
- package/dist/plugin/load-worker.d.ts.map +1 -0
- package/dist/plugin/load-worker.js +41 -0
- package/dist/plugin/load-worker.js.map +1 -0
- package/dist/plugin/load.d.ts +18 -0
- package/dist/plugin/load.d.ts.map +1 -0
- package/dist/plugin/load.js +62 -0
- package/dist/plugin/load.js.map +1 -0
- package/dist/plugin/manifest.d.ts +237 -0
- package/dist/plugin/manifest.d.ts.map +1 -0
- package/dist/plugin/manifest.js +411 -0
- package/dist/plugin/manifest.js.map +1 -0
- package/dist/plugin/refusals.d.ts +49 -0
- package/dist/plugin/refusals.d.ts.map +1 -0
- package/dist/plugin/refusals.js +79 -0
- package/dist/plugin/refusals.js.map +1 -0
- package/dist/plugin/rest-schemas.d.ts +556 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -0
- package/dist/plugin/rest-schemas.js +262 -0
- package/dist/plugin/rest-schemas.js.map +1 -0
- package/dist/plugin/sandbox.d.ts +23 -0
- package/dist/plugin/sandbox.d.ts.map +1 -0
- package/dist/plugin/sandbox.js +99 -0
- package/dist/plugin/sandbox.js.map +1 -0
- package/dist/plugin/testing.d.ts +45 -0
- package/dist/plugin/testing.d.ts.map +1 -0
- package/dist/plugin/testing.js +420 -0
- package/dist/plugin/testing.js.map +1 -0
- package/dist/plugin/web-react.d.ts +11 -0
- package/dist/plugin/web-react.d.ts.map +1 -0
- package/dist/plugin/web-react.js +34 -0
- package/dist/plugin/web-react.js.map +1 -0
- package/dist/plugin/web.d.ts +95 -0
- package/dist/plugin/web.d.ts.map +1 -0
- package/dist/plugin/web.js +245 -0
- package/dist/plugin/web.js.map +1 -0
- package/dist/plugin-nav.d.ts +29 -0
- package/dist/plugin-nav.d.ts.map +1 -0
- package/dist/plugin-nav.js +92 -0
- package/dist/plugin-nav.js.map +1 -0
- package/dist/plugin-testing.d.ts +2 -0
- package/dist/plugin-testing.d.ts.map +1 -0
- package/dist/plugin-testing.js +4 -0
- package/dist/plugin-testing.js.map +1 -0
- package/dist/plugin-web-react.d.ts +3 -0
- package/dist/plugin-web-react.d.ts.map +1 -0
- package/dist/plugin-web-react.js +3 -0
- package/dist/plugin-web-react.js.map +1 -0
- package/dist/plugin-web.d.ts +4 -0
- package/dist/plugin-web.d.ts.map +1 -0
- package/dist/plugin-web.js +5 -0
- package/dist/plugin-web.js.map +1 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +15 -0
- package/dist/plugin.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +47 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +6 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +194 -9
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +98 -17
- package/dist/schemas.js.map +1 -1
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +101 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vocabulary.d.ts +11 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +24 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +2 -0
- package/dist/web.js.map +1 -1
- package/docs/specs/cli.md +12 -5
- package/docs/specs/plugins.md +488 -0
- package/docs/specs/rest.md +11 -9
- package/package.json +26 -8
- package/skills/building-a-console/SKILL.md +25 -26
- package/skills/building-a-console/references/configuring.md +40 -0
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
- package/skills/building-a-plugin/SKILL.md +137 -0
- package/skills/deploying-a-console/SKILL.md +24 -26
- package/skills/deploying-a-console/references/access.md +13 -9
- package/skills/deploying-a-console/references/resources.md +25 -10
- package/skills/deploying-a-console/references/running-it-again.md +15 -0
- package/skills/deploying-a-console/references/sizing.md +2 -0
- package/skills/planning-the-board/SKILL.md +96 -0
- package/skills/planning-the-board/references/a-task.md +36 -0
- package/skills/planning-the-board/references/setting-up.md +44 -0
- package/skills/working-the-board/SKILL.md +43 -34
- package/skills/working-the-board/references/the-pack.md +19 -0
- package/src/api.ts +12 -0
- package/src/cli/cli.ts +737 -55
- package/src/cli/format.ts +368 -10
- package/src/cli/help.ts +50 -6
- package/src/cli/plugin-check.ts +497 -0
- package/src/cli/plugin-cli.ts +237 -0
- package/src/cli/plugin-init.ts +257 -0
- package/src/cli/plugin-main.ts +30 -0
- package/src/client.ts +322 -9
- package/src/compat.ts +2 -2
- package/src/components/board.tsx +62 -14
- package/src/components/comments.tsx +1 -0
- package/src/components/decision-form.tsx +92 -11
- package/src/components/decision-list.tsx +71 -2
- package/src/components/event-feed.tsx +42 -1
- package/src/components/my-decisions.tsx +4 -1
- package/src/components/plugin-frame.tsx +437 -0
- package/src/components/plugin-settings.tsx +1111 -0
- package/src/components/shell.tsx +82 -14
- package/src/components/task-view.tsx +2 -1
- package/src/components/workspace.tsx +18 -0
- package/src/console.tsx +304 -31
- package/src/data.ts +59 -11
- package/src/i18n/en.ts +175 -0
- package/src/i18n/ko.ts +155 -0
- package/src/index.ts +12 -0
- package/src/limits.ts +2 -0
- package/src/plugin/args.ts +526 -0
- package/src/plugin/bridge.ts +314 -0
- package/src/plugin/capabilities.ts +150 -0
- package/src/plugin/context.ts +421 -0
- package/src/plugin/define.ts +792 -0
- package/src/plugin/definition-error.ts +19 -0
- package/src/plugin/limits.ts +70 -0
- package/src/plugin/load-worker.ts +50 -0
- package/src/plugin/load.ts +82 -0
- package/src/plugin/manifest.ts +561 -0
- package/src/plugin/refusals.ts +94 -0
- package/src/plugin/rest-schemas.ts +354 -0
- package/src/plugin/sandbox.ts +123 -0
- package/src/plugin/testing.ts +612 -0
- package/src/plugin/web-react.ts +45 -0
- package/src/plugin/web.ts +403 -0
- package/src/plugin-nav.ts +132 -0
- package/src/plugin-testing.ts +10 -0
- package/src/plugin-web-react.ts +4 -0
- package/src/plugin-web.ts +35 -0
- package/src/plugin.ts +126 -0
- package/src/router.ts +73 -0
- package/src/routes.ts +7 -0
- package/src/schemas.ts +115 -18
- package/src/styles/console.css +330 -1
- package/src/tokens.ts +101 -0
- package/src/vocabulary.ts +30 -0
- package/src/web.ts +2 -0
- package/template/plugin/.gitignore +2 -0
- package/template/plugin/README.md +26 -0
- package/template/plugin/SKILLCDN.md +13 -0
- package/template/plugin/build.mjs +40 -0
- package/template/plugin/console-plugin.json +50 -0
- package/template/plugin/package.json +27 -0
- package/template/plugin/skills/__key__/SKILL.md +23 -0
- package/template/plugin/src/check.test.ts +34 -0
- package/template/plugin/src/server/index.test.ts +41 -0
- package/template/plugin/src/server/index.ts +72 -0
- package/template/plugin/src/web/index.css +51 -0
- package/template/plugin/src/web/index.ts +115 -0
- package/template/plugin/tsconfig.json +17 -0
- package/skills/working-the-board/references/setting-up.md +0 -3
- /package/skills/{working-the-board → planning-the-board}/references/project-rules.md +0 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// A context for a plugin's own tests (docs/specs/plugins.md, "Authoring"): records kept in memory
|
|
2
|
+
// with the collection's schema checked as the host checks it, the console refusing what the test
|
|
3
|
+
// did not provide, as a console without the capability would, and everything the plugin emitted or
|
|
4
|
+
// logged kept for assertions. No network, no database, nothing of a real console.
|
|
5
|
+
import type {
|
|
6
|
+
CollectionDefinition,
|
|
7
|
+
LinkTarget,
|
|
8
|
+
OtherPlugin,
|
|
9
|
+
PluginCaller,
|
|
10
|
+
PluginConsole,
|
|
11
|
+
PluginContext,
|
|
12
|
+
PluginFetchInit,
|
|
13
|
+
PluginFetchResponse,
|
|
14
|
+
PluginInfo,
|
|
15
|
+
PluginRecord,
|
|
16
|
+
PluginRecords,
|
|
17
|
+
PluginSql,
|
|
18
|
+
RecordAggregate,
|
|
19
|
+
RecordAggregation,
|
|
20
|
+
RecordFile,
|
|
21
|
+
RecordFiles,
|
|
22
|
+
RecordPage,
|
|
23
|
+
RecordQuery,
|
|
24
|
+
RecordSchema,
|
|
25
|
+
RecordVersion,
|
|
26
|
+
Where,
|
|
27
|
+
WhereScalar,
|
|
28
|
+
WhereValue,
|
|
29
|
+
} from "./context.js";
|
|
30
|
+
import {
|
|
31
|
+
MAX_PLUGIN_RECORD_BYTES,
|
|
32
|
+
MAX_PLUGIN_RECORD_VERSIONS,
|
|
33
|
+
MAX_PLUGIN_RECORDS,
|
|
34
|
+
PLUGIN_LIST_LIMIT,
|
|
35
|
+
} from "./limits.js";
|
|
36
|
+
import { PluginRefusal } from "./refusals.js";
|
|
37
|
+
|
|
38
|
+
export interface TestConsoleOptions {
|
|
39
|
+
readonly workspace?: PluginConsole["workspace"];
|
|
40
|
+
readonly people?: Partial<PluginConsole["people"]>;
|
|
41
|
+
readonly projects?: Partial<PluginConsole["projects"]>;
|
|
42
|
+
readonly tasks?: Partial<PluginConsole["tasks"]>;
|
|
43
|
+
readonly decisions?: Partial<PluginConsole["decisions"]>;
|
|
44
|
+
readonly documents?: Partial<PluginConsole["documents"]>;
|
|
45
|
+
readonly runs?: Partial<PluginConsole["runs"]>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TestContextOptions {
|
|
49
|
+
readonly plugin?: Partial<PluginInfo>;
|
|
50
|
+
readonly caller?: PluginCaller;
|
|
51
|
+
/** The collections, so that writes are checked against their schemas and `where` against their indexes. */
|
|
52
|
+
readonly collections?: readonly CollectionDefinition[];
|
|
53
|
+
/** What the console answers; what is left out is refused as `plugin.forbidden`. */
|
|
54
|
+
readonly console?: TestConsoleOptions;
|
|
55
|
+
readonly sql?: PluginSql;
|
|
56
|
+
readonly fetch?: (url: string, init?: PluginFetchInit) => Promise<PluginFetchResponse>;
|
|
57
|
+
/** The secrets by name; `sign` and `verify` use them, and never hand them over. */
|
|
58
|
+
readonly secrets?: Readonly<Record<string, string>>;
|
|
59
|
+
readonly plugins?: Readonly<Record<string, OtherPlugin>>;
|
|
60
|
+
readonly now?: () => Date;
|
|
61
|
+
readonly language?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LoggedLine {
|
|
65
|
+
readonly level: "info" | "warn" | "error";
|
|
66
|
+
readonly message: string;
|
|
67
|
+
readonly fields: Readonly<Record<string, unknown>> | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TestContext extends PluginContext {
|
|
71
|
+
/** What the plugin emitted with `ctx.events.emit`, in order. */
|
|
72
|
+
readonly emitted: readonly { readonly kind: string; readonly data: unknown }[];
|
|
73
|
+
readonly logged: readonly LoggedLine[];
|
|
74
|
+
/** Every record of a collection, archived ones included, for assertions. */
|
|
75
|
+
all(collection: string): readonly PluginRecord[];
|
|
76
|
+
/** The same store, as another caller. */
|
|
77
|
+
as(caller: PluginCaller): TestContext;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Kept {
|
|
81
|
+
record: PluginRecord;
|
|
82
|
+
versions: RecordVersion[];
|
|
83
|
+
links: LinkTarget[];
|
|
84
|
+
files: RecordFile[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Store {
|
|
88
|
+
readonly collections: Map<string, Map<string, Kept>>;
|
|
89
|
+
readonly emitted: { kind: string; data: unknown }[];
|
|
90
|
+
readonly logged: LoggedLine[];
|
|
91
|
+
counter: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_CALLER: PluginCaller = {
|
|
95
|
+
kind: "person",
|
|
96
|
+
person: { id: "person-1", login: "tester", name: "Tester", language: "en", admin: false },
|
|
97
|
+
agent: null,
|
|
98
|
+
roles: [],
|
|
99
|
+
run: null,
|
|
100
|
+
project: null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function forbidden(what: string, give: string): never {
|
|
104
|
+
throw new PluginRefusal(
|
|
105
|
+
"plugin.forbidden",
|
|
106
|
+
`${what}: the test context was given no ${give} to answer it`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scalarOf(value: unknown): WhereScalar | undefined {
|
|
111
|
+
return typeof value === "string" ||
|
|
112
|
+
typeof value === "number" ||
|
|
113
|
+
typeof value === "boolean" ||
|
|
114
|
+
value === null
|
|
115
|
+
? value
|
|
116
|
+
: undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compare(a: WhereScalar | undefined, b: WhereScalar | undefined): number {
|
|
120
|
+
if (a === b) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (a === undefined || a === null) {
|
|
124
|
+
return -1;
|
|
125
|
+
}
|
|
126
|
+
if (b === undefined || b === null) {
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
130
|
+
return a - b;
|
|
131
|
+
}
|
|
132
|
+
return String(a) < String(b) ? -1 : 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fieldOf(record: PluginRecord, field: string): WhereScalar | undefined {
|
|
136
|
+
if (field === "owner" || field === "project" || field === "id") {
|
|
137
|
+
return record[field];
|
|
138
|
+
}
|
|
139
|
+
return scalarOf(record.data[field]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function matches(record: PluginRecord, where: Where): boolean {
|
|
143
|
+
return Object.entries(where).every(([field, condition]) => {
|
|
144
|
+
const value = fieldOf(record, field);
|
|
145
|
+
return conditionHolds(value, condition);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function conditionHolds(value: WhereScalar | undefined, condition: WhereValue): boolean {
|
|
150
|
+
if (condition === null || typeof condition !== "object") {
|
|
151
|
+
return value === condition;
|
|
152
|
+
}
|
|
153
|
+
if ("in" in condition && Array.isArray(condition.in)) {
|
|
154
|
+
return condition.in.includes(value as WhereScalar);
|
|
155
|
+
}
|
|
156
|
+
if ("exists" in condition && typeof condition.exists === "boolean") {
|
|
157
|
+
return (value !== undefined && value !== null) === condition.exists;
|
|
158
|
+
}
|
|
159
|
+
const bounds = condition as {
|
|
160
|
+
gte?: WhereScalar;
|
|
161
|
+
gt?: WhereScalar;
|
|
162
|
+
lte?: WhereScalar;
|
|
163
|
+
lt?: WhereScalar;
|
|
164
|
+
};
|
|
165
|
+
if (value === undefined || value === null) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (bounds.gte !== undefined && compare(value, bounds.gte) < 0) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (bounds.gt !== undefined && compare(value, bounds.gt) <= 0) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (bounds.lte !== undefined && compare(value, bounds.lte) > 0) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (bounds.lt !== undefined && compare(value, bounds.lt) >= 0) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function validated<T>(
|
|
184
|
+
collection: CollectionDefinition | undefined,
|
|
185
|
+
name: string,
|
|
186
|
+
data: unknown,
|
|
187
|
+
): Promise<T> {
|
|
188
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
189
|
+
throw new PluginRefusal("plugin.invalid_args", `${name}: a record's data is an object`);
|
|
190
|
+
}
|
|
191
|
+
if (JSON.stringify(data).length > MAX_PLUGIN_RECORD_BYTES) {
|
|
192
|
+
throw new PluginRefusal(
|
|
193
|
+
"plugin.invalid_args",
|
|
194
|
+
`${name}: a record is ${MAX_PLUGIN_RECORD_BYTES} bytes at most`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (collection === undefined) {
|
|
198
|
+
return data as T;
|
|
199
|
+
}
|
|
200
|
+
const result = await (collection.schema as RecordSchema<T>)["~standard"].validate(data);
|
|
201
|
+
if (result.issues !== undefined) {
|
|
202
|
+
const first = result.issues[0];
|
|
203
|
+
const path = (first?.path ?? [])
|
|
204
|
+
.map((segment) => (typeof segment === "object" ? String(segment.key) : String(segment)))
|
|
205
|
+
.join(".");
|
|
206
|
+
throw new PluginRefusal(
|
|
207
|
+
"plugin.invalid_args",
|
|
208
|
+
`${name}: ${path.length > 0 ? `${path}: ` : ""}${first?.message ?? "does not fit the schema"}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return result.value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function memoryRecords<T extends Record<string, unknown>>(
|
|
215
|
+
store: Store,
|
|
216
|
+
name: string,
|
|
217
|
+
collection: CollectionDefinition | undefined,
|
|
218
|
+
caller: PluginCaller,
|
|
219
|
+
now: () => Date,
|
|
220
|
+
): PluginRecords<T> {
|
|
221
|
+
const kept = (): Map<string, Kept> => {
|
|
222
|
+
let found = store.collections.get(name);
|
|
223
|
+
if (found === undefined) {
|
|
224
|
+
found = new Map();
|
|
225
|
+
store.collections.set(name, found);
|
|
226
|
+
}
|
|
227
|
+
return found;
|
|
228
|
+
};
|
|
229
|
+
const by = caller.kind === "person" ? caller.person.id : null;
|
|
230
|
+
const indexed = (field: string) => {
|
|
231
|
+
if (
|
|
232
|
+
collection !== undefined &&
|
|
233
|
+
!collection.indexes.includes(field) &&
|
|
234
|
+
!["owner", "project", "id"].includes(field)
|
|
235
|
+
) {
|
|
236
|
+
throw new PluginRefusal(
|
|
237
|
+
"plugin.invalid_args",
|
|
238
|
+
`${name}: ${field} is not an indexed field of the collection`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const one = (id: string): Kept => {
|
|
243
|
+
const found = kept().get(id);
|
|
244
|
+
if (found === undefined) {
|
|
245
|
+
throw new PluginRefusal("plugin.not_found", `${name}: no record ${id}`);
|
|
246
|
+
}
|
|
247
|
+
return found;
|
|
248
|
+
};
|
|
249
|
+
const page = (records: PluginRecord<T>[], query: RecordQuery): RecordPage<T> => {
|
|
250
|
+
const limit = Math.min(Math.max(query.limit ?? PLUGIN_LIST_LIMIT, 1), PLUGIN_LIST_LIMIT);
|
|
251
|
+
const field = query.order?.field;
|
|
252
|
+
if (field !== undefined) {
|
|
253
|
+
indexed(field);
|
|
254
|
+
}
|
|
255
|
+
const sorted = [...records].sort((a, b) => {
|
|
256
|
+
const order = field === undefined ? 0 : compare(fieldOf(a, field), fieldOf(b, field));
|
|
257
|
+
const signed = query.order?.descending === true ? -order : order;
|
|
258
|
+
return signed !== 0 ? signed : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
259
|
+
});
|
|
260
|
+
const start =
|
|
261
|
+
query.after === undefined ? 0 : sorted.findIndex((record) => record.id === query.after) + 1;
|
|
262
|
+
const items = sorted.slice(start, start + limit);
|
|
263
|
+
const last = items.at(-1);
|
|
264
|
+
return { items, after: start + limit < sorted.length && last !== undefined ? last.id : null };
|
|
265
|
+
};
|
|
266
|
+
const visible = (query: RecordQuery): PluginRecord<T>[] =>
|
|
267
|
+
[...kept().values()]
|
|
268
|
+
.map((entry) => entry.record as PluginRecord<T>)
|
|
269
|
+
.filter((record) => (query.archived === true ? true : record.archivedAt === null))
|
|
270
|
+
.filter((record) => (query.project === undefined ? true : record.project === query.project))
|
|
271
|
+
.filter((record) => {
|
|
272
|
+
for (const field of Object.keys(query.where ?? {})) {
|
|
273
|
+
indexed(field);
|
|
274
|
+
}
|
|
275
|
+
return query.where === undefined ? true : matches(record, query.where);
|
|
276
|
+
});
|
|
277
|
+
const files = (id: string): RecordFiles => ({
|
|
278
|
+
attach: async (handle, options) => {
|
|
279
|
+
const entry = one(id);
|
|
280
|
+
const given =
|
|
281
|
+
typeof handle === "string"
|
|
282
|
+
? { handle, name: "file", type: "application/octet-stream", bytes: 0 }
|
|
283
|
+
: handle;
|
|
284
|
+
const file: RecordFile = {
|
|
285
|
+
id: `${entry.record.id}-file-${entry.files.length + 1}`,
|
|
286
|
+
name: options?.name ?? given.name,
|
|
287
|
+
type: options?.type ?? given.type,
|
|
288
|
+
bytes: given.bytes,
|
|
289
|
+
sha256: "",
|
|
290
|
+
createdAt: now().toISOString(),
|
|
291
|
+
};
|
|
292
|
+
entry.files.push(file);
|
|
293
|
+
return file;
|
|
294
|
+
},
|
|
295
|
+
list: async () => [...one(id).files],
|
|
296
|
+
remove: async (fileId) => {
|
|
297
|
+
const entry = one(id);
|
|
298
|
+
entry.files = entry.files.filter((file) => file.id !== fileId);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
async create(data, options) {
|
|
303
|
+
const records = kept();
|
|
304
|
+
if (records.size >= MAX_PLUGIN_RECORDS) {
|
|
305
|
+
throw new PluginRefusal(
|
|
306
|
+
"plugin.too_many",
|
|
307
|
+
`${name}: ${MAX_PLUGIN_RECORDS} records at most`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (collection?.project === "required" && options?.project === undefined) {
|
|
311
|
+
throw new PluginRefusal(
|
|
312
|
+
"plugin.invalid_args",
|
|
313
|
+
`${name}: a record of this collection is linked to a project`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const checked = await validated<T>(collection, name, data);
|
|
317
|
+
store.counter += 1;
|
|
318
|
+
const at = now().toISOString();
|
|
319
|
+
const owner =
|
|
320
|
+
options?.owner ??
|
|
321
|
+
(collection?.owner !== null && collection?.owner !== undefined
|
|
322
|
+
? scalarOf(checked[collection.owner])
|
|
323
|
+
: null);
|
|
324
|
+
const record: PluginRecord<T> = {
|
|
325
|
+
id: `${name}-${String(store.counter).padStart(6, "0")}`,
|
|
326
|
+
collection: name,
|
|
327
|
+
project: options?.project ?? null,
|
|
328
|
+
owner: typeof owner === "string" ? owner : null,
|
|
329
|
+
data: checked,
|
|
330
|
+
version: 1,
|
|
331
|
+
createdAt: at,
|
|
332
|
+
updatedAt: at,
|
|
333
|
+
createdBy: by,
|
|
334
|
+
updatedBy: by,
|
|
335
|
+
archivedAt: null,
|
|
336
|
+
};
|
|
337
|
+
records.set(record.id, { record, versions: [], links: [], files: [] });
|
|
338
|
+
return record;
|
|
339
|
+
},
|
|
340
|
+
async get(id) {
|
|
341
|
+
return (kept().get(id)?.record as PluginRecord<T> | undefined) ?? null;
|
|
342
|
+
},
|
|
343
|
+
async update(id, patch, options) {
|
|
344
|
+
const entry = one(id);
|
|
345
|
+
if (options?.baseVersion !== undefined && options.baseVersion !== entry.record.version) {
|
|
346
|
+
throw new PluginRefusal(
|
|
347
|
+
"plugin.conflict",
|
|
348
|
+
`${name}: the record ${id} is at version ${entry.record.version}, not ${options.baseVersion}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const checked = await validated<T>(collection, name, { ...entry.record.data, ...patch });
|
|
352
|
+
if (collection?.history !== false) {
|
|
353
|
+
entry.versions.push({
|
|
354
|
+
version: entry.record.version,
|
|
355
|
+
data: entry.record.data,
|
|
356
|
+
at: entry.record.updatedAt,
|
|
357
|
+
by: entry.record.updatedBy,
|
|
358
|
+
});
|
|
359
|
+
if (entry.versions.length > MAX_PLUGIN_RECORD_VERSIONS) {
|
|
360
|
+
entry.versions.shift();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
entry.record = {
|
|
364
|
+
...entry.record,
|
|
365
|
+
data: checked,
|
|
366
|
+
version: entry.record.version + 1,
|
|
367
|
+
updatedAt: now().toISOString(),
|
|
368
|
+
updatedBy: by,
|
|
369
|
+
};
|
|
370
|
+
return entry.record as PluginRecord<T>;
|
|
371
|
+
},
|
|
372
|
+
async archive(id) {
|
|
373
|
+
const entry = one(id);
|
|
374
|
+
entry.record = { ...entry.record, archivedAt: now().toISOString() };
|
|
375
|
+
},
|
|
376
|
+
async restore(id) {
|
|
377
|
+
const entry = one(id);
|
|
378
|
+
entry.record = { ...entry.record, archivedAt: null };
|
|
379
|
+
},
|
|
380
|
+
async list(query = {}) {
|
|
381
|
+
return page(visible(query), query);
|
|
382
|
+
},
|
|
383
|
+
async search(words, options = {}) {
|
|
384
|
+
const needles = words
|
|
385
|
+
.toLowerCase()
|
|
386
|
+
.split(/\s+/)
|
|
387
|
+
.filter((word) => word.length > 0);
|
|
388
|
+
const fields = collection?.search ?? [];
|
|
389
|
+
const found = visible({ where: options.where, project: options.project }).filter((record) => {
|
|
390
|
+
const text = (fields.length > 0 ? fields : Object.keys(record.data))
|
|
391
|
+
.map((field) => String(record.data[field] ?? ""))
|
|
392
|
+
.join(" ")
|
|
393
|
+
.toLowerCase();
|
|
394
|
+
return needles.every((needle) => text.includes(needle));
|
|
395
|
+
});
|
|
396
|
+
return page(found, { limit: options.limit });
|
|
397
|
+
},
|
|
398
|
+
async aggregate(aggregation) {
|
|
399
|
+
const groupBy = aggregation.groupBy ?? [];
|
|
400
|
+
if (groupBy.length > 2) {
|
|
401
|
+
throw new PluginRefusal(
|
|
402
|
+
"plugin.invalid_args",
|
|
403
|
+
`${name}: aggregate groups by two fields at most`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
for (const field of groupBy) {
|
|
407
|
+
indexed(field);
|
|
408
|
+
}
|
|
409
|
+
const groups = new Map<
|
|
410
|
+
string,
|
|
411
|
+
RecordAggregate & {
|
|
412
|
+
count: number;
|
|
413
|
+
sum: Record<string, number>;
|
|
414
|
+
min: Record<string, WhereScalar>;
|
|
415
|
+
max: Record<string, WhereScalar>;
|
|
416
|
+
}
|
|
417
|
+
>();
|
|
418
|
+
for (const record of visible({ where: aggregation.where })) {
|
|
419
|
+
const group = Object.fromEntries(
|
|
420
|
+
groupBy.map((field) => [field, fieldOf(record, field) ?? null]),
|
|
421
|
+
);
|
|
422
|
+
const key = JSON.stringify(group);
|
|
423
|
+
let row = groups.get(key);
|
|
424
|
+
if (row === undefined) {
|
|
425
|
+
row = { group, count: 0, sum: {}, min: {}, max: {} };
|
|
426
|
+
groups.set(key, row);
|
|
427
|
+
}
|
|
428
|
+
row.count += 1;
|
|
429
|
+
for (const field of aggregation.sum ?? []) {
|
|
430
|
+
const value = fieldOf(record, field);
|
|
431
|
+
row.sum[field] = (row.sum[field] ?? 0) + (typeof value === "number" ? value : 0);
|
|
432
|
+
}
|
|
433
|
+
for (const field of aggregation.min ?? []) {
|
|
434
|
+
const value = fieldOf(record, field);
|
|
435
|
+
if (
|
|
436
|
+
value !== undefined &&
|
|
437
|
+
value !== null &&
|
|
438
|
+
(row.min[field] === undefined || compare(value, row.min[field]) < 0)
|
|
439
|
+
) {
|
|
440
|
+
row.min[field] = value;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const field of aggregation.max ?? []) {
|
|
444
|
+
const value = fieldOf(record, field);
|
|
445
|
+
if (
|
|
446
|
+
value !== undefined &&
|
|
447
|
+
value !== null &&
|
|
448
|
+
(row.max[field] === undefined || compare(value, row.max[field]) > 0)
|
|
449
|
+
) {
|
|
450
|
+
row.max[field] = value;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return [...groups.values()].map((row) => shape(row, aggregation));
|
|
455
|
+
},
|
|
456
|
+
async versions(id) {
|
|
457
|
+
return [...one(id).versions] as RecordVersion<T>[];
|
|
458
|
+
},
|
|
459
|
+
async link(id, target) {
|
|
460
|
+
const entry = one(id);
|
|
461
|
+
if (!entry.links.some((link) => JSON.stringify(link) === JSON.stringify(target))) {
|
|
462
|
+
entry.links.push(target);
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
async unlink(id, target) {
|
|
466
|
+
const entry = one(id);
|
|
467
|
+
entry.links = entry.links.filter((link) => JSON.stringify(link) !== JSON.stringify(target));
|
|
468
|
+
},
|
|
469
|
+
async links(id) {
|
|
470
|
+
return [...one(id).links];
|
|
471
|
+
},
|
|
472
|
+
files,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function shape(
|
|
477
|
+
row: RecordAggregate & {
|
|
478
|
+
count: number;
|
|
479
|
+
sum: Record<string, number>;
|
|
480
|
+
min: Record<string, WhereScalar>;
|
|
481
|
+
max: Record<string, WhereScalar>;
|
|
482
|
+
},
|
|
483
|
+
aggregation: RecordAggregation,
|
|
484
|
+
): RecordAggregate {
|
|
485
|
+
return {
|
|
486
|
+
group: row.group,
|
|
487
|
+
...(aggregation.count === true ? { count: row.count } : {}),
|
|
488
|
+
...((aggregation.sum ?? []).length > 0 ? { sum: row.sum } : {}),
|
|
489
|
+
...((aggregation.min ?? []).length > 0 ? { min: row.min } : {}),
|
|
490
|
+
...((aggregation.max ?? []).length > 0 ? { max: row.max } : {}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function hmacSha256(secret: string, payload: string | Uint8Array): Promise<string> {
|
|
495
|
+
const encoder = new TextEncoder();
|
|
496
|
+
const key = await crypto.subtle.importKey(
|
|
497
|
+
"raw",
|
|
498
|
+
encoder.encode(secret),
|
|
499
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
500
|
+
false,
|
|
501
|
+
["sign"],
|
|
502
|
+
);
|
|
503
|
+
const bytes = typeof payload === "string" ? encoder.encode(payload) : payload;
|
|
504
|
+
const signature = await crypto.subtle.sign("HMAC", key, bytes as BufferSource);
|
|
505
|
+
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function refusingConsole(given: TestConsoleOptions | undefined): PluginConsole {
|
|
509
|
+
const refuse = (group: string, call: string) => async () =>
|
|
510
|
+
forbidden(`console.${group}.${call}`, `console.${group}.${call}`);
|
|
511
|
+
const fill = <G extends Record<string, (...args: never[]) => unknown>>(
|
|
512
|
+
group: string,
|
|
513
|
+
names: readonly (keyof G & string)[],
|
|
514
|
+
own: Partial<G> | undefined,
|
|
515
|
+
): G =>
|
|
516
|
+
Object.fromEntries(
|
|
517
|
+
names.map((name) => [name, own?.[name] ?? refuse(group, name)]),
|
|
518
|
+
) as unknown as G;
|
|
519
|
+
return {
|
|
520
|
+
workspace:
|
|
521
|
+
given?.workspace ?? (async () => forbidden("console.workspace", "console.workspace")),
|
|
522
|
+
people: fill("people", ["list", "get"], given?.people),
|
|
523
|
+
projects: fill("projects", ["list", "get", "members"], given?.projects),
|
|
524
|
+
tasks: fill("tasks", ["list", "get", "create", "update", "comment"], given?.tasks),
|
|
525
|
+
decisions: fill("decisions", ["list", "get", "ask"], given?.decisions),
|
|
526
|
+
documents: fill("documents", ["list", "read", "write"], given?.documents),
|
|
527
|
+
runs: fill("runs", ["list", "get"], given?.runs),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function build(store: Store, options: TestContextOptions, caller: PluginCaller): TestContext {
|
|
532
|
+
const now = options.now ?? (() => new Date());
|
|
533
|
+
const collections = new Map(
|
|
534
|
+
(options.collections ?? []).map((collection) => [collection.name, collection]),
|
|
535
|
+
);
|
|
536
|
+
const plugin: PluginInfo = {
|
|
537
|
+
key: "test",
|
|
538
|
+
version: "0.0.0",
|
|
539
|
+
dataVersion: 1,
|
|
540
|
+
settings: {},
|
|
541
|
+
board: null,
|
|
542
|
+
projects: "all",
|
|
543
|
+
...options.plugin,
|
|
544
|
+
};
|
|
545
|
+
const secrets = options.secrets ?? {};
|
|
546
|
+
const records = ((collection: CollectionDefinition | string) => {
|
|
547
|
+
const name = typeof collection === "string" ? collection : collection.name;
|
|
548
|
+
const definition = typeof collection === "string" ? collections.get(collection) : collection;
|
|
549
|
+
return memoryRecords(store, name, definition, caller, now);
|
|
550
|
+
}) as PluginContext["records"];
|
|
551
|
+
const log =
|
|
552
|
+
(level: LoggedLine["level"]) =>
|
|
553
|
+
(message: string, fields?: Readonly<Record<string, unknown>>) => {
|
|
554
|
+
store.logged.push({ level, message, fields });
|
|
555
|
+
};
|
|
556
|
+
return {
|
|
557
|
+
plugin,
|
|
558
|
+
caller,
|
|
559
|
+
records,
|
|
560
|
+
sql: options.sql ?? {
|
|
561
|
+
query: async () => forbidden("sql.query", "sql"),
|
|
562
|
+
transaction: async () => forbidden("sql.transaction", "sql"),
|
|
563
|
+
},
|
|
564
|
+
console: refusingConsole(options.console),
|
|
565
|
+
events: {
|
|
566
|
+
async emit(kind, data) {
|
|
567
|
+
store.emitted.push({ kind, data });
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
fetch: options.fetch ?? (async () => forbidden("fetch", "fetch")),
|
|
571
|
+
secrets: {
|
|
572
|
+
has: async (name) => name in secrets,
|
|
573
|
+
sign: async (name, payload) => {
|
|
574
|
+
const secret = secrets[name];
|
|
575
|
+
if (secret === undefined) {
|
|
576
|
+
throw new PluginRefusal("plugin.forbidden", `the secret ${name} is not set`);
|
|
577
|
+
}
|
|
578
|
+
return hmacSha256(secret, payload);
|
|
579
|
+
},
|
|
580
|
+
verify: async (name, payload, signature) => {
|
|
581
|
+
const secret = secrets[name];
|
|
582
|
+
return (
|
|
583
|
+
secret !== undefined && (await hmacSha256(secret, payload)) === signature.toLowerCase()
|
|
584
|
+
);
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
plugins: {
|
|
588
|
+
async get(key) {
|
|
589
|
+
const other = options.plugins?.[key];
|
|
590
|
+
if (other === undefined) {
|
|
591
|
+
forbidden(`plugins.get(${key})`, `plugins.${key}`);
|
|
592
|
+
}
|
|
593
|
+
return other;
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
log: { info: log("info"), warn: log("warn"), error: log("error") },
|
|
597
|
+
now,
|
|
598
|
+
language: options.language ?? (caller.kind === "person" ? caller.person.language : "en"),
|
|
599
|
+
signal: new AbortController().signal,
|
|
600
|
+
emitted: store.emitted,
|
|
601
|
+
logged: store.logged,
|
|
602
|
+
all: (collection) =>
|
|
603
|
+
[...(store.collections.get(collection)?.values() ?? [])].map((entry) => entry.record),
|
|
604
|
+
as: (other) => build(store, options, other),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** A context for a plugin's tests: records in memory, the console refusing what is not given. */
|
|
609
|
+
export function createTestContext(options: TestContextOptions = {}): TestContext {
|
|
610
|
+
const store: Store = { collections: new Map(), emitted: [], logged: [], counter: 0 };
|
|
611
|
+
return build(store, options, options.caller ?? DEFAULT_CALLER);
|
|
612
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// For a plugin that chooses React: the connection as a hook. One connection per document, made
|
|
2
|
+
// on first use and shared; the component renders nothing of the console's until `frame` is there.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { type ConnectOptions, connect, type PluginFrame } from "./web.js";
|
|
5
|
+
|
|
6
|
+
let shared: Promise<PluginFrame> | undefined;
|
|
7
|
+
|
|
8
|
+
/** The one connection of this document, made with `connect()` on first call. */
|
|
9
|
+
export function sharedConnection(options?: ConnectOptions): Promise<PluginFrame> {
|
|
10
|
+
shared ??= connect(options);
|
|
11
|
+
return shared;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UsePlugin {
|
|
15
|
+
/** The connection once the console answered; `null` before, and when it failed. */
|
|
16
|
+
readonly frame: PluginFrame | null;
|
|
17
|
+
readonly error: Error | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Connects to the console's page once, and answers the connection when it is there. */
|
|
21
|
+
export function usePlugin(options?: ConnectOptions): UsePlugin {
|
|
22
|
+
const [state, setState] = useState<UsePlugin>({ frame: null, error: null });
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let live = true;
|
|
25
|
+
sharedConnection(options).then(
|
|
26
|
+
(frame) => {
|
|
27
|
+
if (live) {
|
|
28
|
+
setState({ frame, error: null });
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
(error: unknown) => {
|
|
32
|
+
if (live) {
|
|
33
|
+
setState({
|
|
34
|
+
frame: null,
|
|
35
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
return () => {
|
|
41
|
+
live = false;
|
|
42
|
+
};
|
|
43
|
+
}, [options]);
|
|
44
|
+
return state;
|
|
45
|
+
}
|