apps 0.8.2 → 1.0.0-beta.0

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.
Files changed (49) hide show
  1. package/README.md +51 -166
  2. package/dist/authoring/index.d.ts +205 -0
  3. package/dist/authoring/index.js +26 -0
  4. package/dist/index-8wmxmfhc.js +90 -0
  5. package/dist/index-dh4z81es.js +10 -0
  6. package/dist/index-mcjwntr2.js +13 -0
  7. package/dist/index-wfngzvv2.js +228 -0
  8. package/dist/index.d.ts +9 -0
  9. package/dist/index.js +26 -0
  10. package/dist/manifest/collect.d.ts +64 -0
  11. package/dist/manifest/contract.d.ts +55 -0
  12. package/dist/manifest/contract.js +8 -0
  13. package/dist/manifest/index.d.ts +18 -0
  14. package/dist/manifest/index.js +15 -0
  15. package/dist/runner/index.d.ts +4 -0
  16. package/dist/runner/index.js +61 -0
  17. package/dist/runner/protocol.d.ts +28 -0
  18. package/dist/runner/protocol.js +6 -0
  19. package/dist/workerd/index.d.ts +2 -0
  20. package/dist/workerd/index.js +3 -0
  21. package/package.json +49 -36
  22. package/src/authoring/index.ts +340 -0
  23. package/src/index.ts +9 -0
  24. package/src/manifest/collect.ts +198 -0
  25. package/src/manifest/contract.ts +55 -0
  26. package/src/manifest/index.ts +224 -0
  27. package/src/runner/index.ts +117 -0
  28. package/src/runner/protocol.ts +41 -0
  29. package/src/workerd/index.ts +2 -0
  30. package/.npmignore +0 -1
  31. package/bin/apps +0 -36
  32. package/bin/build +0 -14
  33. package/bin/doc +0 -16
  34. package/dist/app.js +0 -9773
  35. package/dist/app.min.js +0 -5
  36. package/dist/schema.html +0 -119
  37. package/dist/schema.md +0 -156
  38. package/index.html +0 -6
  39. package/index.js +0 -115
  40. package/lib/addons.js +0 -67
  41. package/schema.js +0 -100
  42. package/templates/app.mustache.html +0 -36
  43. package/templates/build.mustache.html +0 -13
  44. package/templates/schema.mustache.html +0 -20
  45. package/test/addons.js +0 -172
  46. package/test/cli.js +0 -53
  47. package/test/fixtures/app.json +0 -32
  48. package/test/index.js +0 -277
  49. package/test/mocha.opts +0 -2
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Manifest: execute an authored app folder, read its registry, validate
3
+ * every claim, and emit the deterministic publish manifest. Defines run
4
+ * without user context; platform services begin at this boundary.
5
+ */
6
+
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { Crypto, Effect, FileSystem, Schema, Semaphore } from "effect";
9
+ import {
10
+ __enterFile,
11
+ __leaveFile,
12
+ newRegistry,
13
+ setActiveRegistry,
14
+ type Registry,
15
+ } from "../authoring/index";
16
+ import { collectRegistry, type InputSchemaCompilation } from "./collect";
17
+ import {
18
+ JsonSchemaObject,
19
+ PublishError,
20
+ type AppManifestResult,
21
+ type JsonSchemaObject as JsonSchemaObjectType,
22
+ } from "./contract";
23
+
24
+ export * from "./contract";
25
+
26
+ interface StandardJsonSchemaCapable {
27
+ readonly "~standard": {
28
+ readonly jsonSchema?: {
29
+ readonly input: (options: { readonly target: string }) => unknown;
30
+ };
31
+ };
32
+ }
33
+
34
+ const hasStandardJsonSchema = (input: object): input is StandardJsonSchemaCapable =>
35
+ "~standard" in input &&
36
+ typeof (input as StandardJsonSchemaCapable)["~standard"].jsonSchema?.input === "function";
37
+
38
+ const compileInputSchema = (
39
+ input: object,
40
+ where: string,
41
+ ): InputSchemaCompilation => {
42
+ if ("~standard" in input) {
43
+ if (!hasStandardJsonSchema(input))
44
+ return {
45
+ problem: `${where}: input is a Standard Schema without the jsonSchema extension — use a library that implements it (zod 4+, effect Schema) or pass raw JSON schema`,
46
+ };
47
+ }
48
+
49
+ try {
50
+ const compiled = strictify(
51
+ hasStandardJsonSchema(input)
52
+ ? input["~standard"].jsonSchema!.input({ target: "draft-2020-12" })
53
+ : structuredClone(input),
54
+ );
55
+ return { value: Schema.decodeUnknownSync(JsonSchemaObject)(compiled) as JsonSchemaObjectType };
56
+ } catch (error) {
57
+ return { problem: schemaPublishProblem(where, error) };
58
+ }
59
+ };
60
+
61
+ const schemaPublishProblem = (where: string, error: unknown): string =>
62
+ `${where}: input schema could not compile to JSON schema (${
63
+ error instanceof Error ? error.message : String(error)
64
+ })`;
65
+
66
+ const strictify = (node: unknown): unknown => {
67
+ if (typeof node !== "object" || node === null) return node;
68
+ if (Array.isArray(node)) return node.map(strictify);
69
+ const object = { ...(node as Record<string, unknown>) };
70
+ if (object.type === "object" && object.additionalProperties === undefined)
71
+ object.additionalProperties = false;
72
+ for (const key of ["items", "anyOf", "oneOf", "allOf"])
73
+ if (key in object) object[key] = strictify(object[key]);
74
+ for (const key of ["properties", "$defs"])
75
+ if (object[key] !== undefined && typeof object[key] === "object" && object[key] !== null)
76
+ object[key] = Object.fromEntries(
77
+ Object.entries(object[key] as Record<string, unknown>).map(([name, value]) => [
78
+ name,
79
+ strictify(value),
80
+ ]),
81
+ );
82
+ return object;
83
+ };
84
+
85
+ const digestOf = (content: string): Effect.Effect<string, never, Crypto.Crypto> =>
86
+ Effect.gen(function* () {
87
+ const crypto = yield* Crypto.Crypto;
88
+ const bytes = yield* Effect.orDie(
89
+ crypto.digest("SHA-256", new TextEncoder().encode(content)),
90
+ );
91
+ return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
92
+ });
93
+
94
+ const loadPermit = Semaphore.makeUnsafe(1);
95
+ let runNonce = 0;
96
+ const baseAppImports = new Set<string>();
97
+
98
+ const moduleFailure = (where: string, error: unknown): PublishError =>
99
+ new PublishError({
100
+ problems: [
101
+ `${where}: failed to execute (${error instanceof Error ? error.message : String(error)})`,
102
+ ],
103
+ });
104
+
105
+ const canonicalModuleUrl = (
106
+ path: string,
107
+ where: string,
108
+ ): Effect.Effect<URL, PublishError, FileSystem.FileSystem> =>
109
+ Effect.gen(function* () {
110
+ const fs = yield* FileSystem.FileSystem;
111
+ const realPath = yield* fs.realPath(path).pipe(
112
+ Effect.mapError((error) => moduleFailure(where, error)),
113
+ );
114
+ return pathToFileURL(realPath);
115
+ });
116
+
117
+ const importModule = (specifier: string, where: string): Effect.Effect<void, PublishError> =>
118
+ Effect.tryPromise({
119
+ try: () => import(specifier).then(() => undefined),
120
+ catch: (error) => moduleFailure(where, error),
121
+ });
122
+
123
+ const recordModuleProblems = (
124
+ registry: Registry,
125
+ effect: Effect.Effect<void, PublishError, FileSystem.FileSystem>,
126
+ ): Effect.Effect<void, never, FileSystem.FileSystem> =>
127
+ effect.pipe(
128
+ Effect.catchTag("PublishError", (error) =>
129
+ Effect.sync(() => {
130
+ registry.problems.push(...error.problems.map((message) => ({ message })));
131
+ }),
132
+ ),
133
+ );
134
+
135
+ const executeModules = (
136
+ appDir: string,
137
+ files: readonly string[],
138
+ ): Effect.Effect<Registry, never, FileSystem.FileSystem> =>
139
+ loadPermit.withPermit(
140
+ Effect.uninterruptibleMask((restore) =>
141
+ Effect.gen(function* () {
142
+ const registry = newRegistry();
143
+ const previous = yield* Effect.sync(() => setActiveRegistry(registry));
144
+ const nonce = yield* Effect.sync(() => ++runNonce);
145
+
146
+ const execute = Effect.gen(function* () {
147
+ __enterFile("app.ts");
148
+ yield* recordModuleProblems(
149
+ registry,
150
+ Effect.gen(function* () {
151
+ const appUrl = yield* canonicalModuleUrl(`${appDir}/app.ts`, "app.ts");
152
+ const baseUrl = appUrl.href;
153
+ const specifier = baseAppImports.has(baseUrl)
154
+ ? `${fileURLToPath(appUrl)}?m=${nonce}`
155
+ : baseUrl;
156
+ yield* importModule(specifier, "app.ts");
157
+ yield* Effect.sync(() => baseAppImports.add(baseUrl));
158
+ }),
159
+ );
160
+
161
+ for (const file of files) {
162
+ const where = `tools/${file}`;
163
+ __enterFile(where);
164
+ yield* recordModuleProblems(
165
+ registry,
166
+ Effect.gen(function* () {
167
+ const url = yield* canonicalModuleUrl(`${appDir}/tools/${file}`, where);
168
+ yield* importModule(`${fileURLToPath(url)}?m=${nonce}`, where);
169
+ }),
170
+ );
171
+ }
172
+ });
173
+
174
+ yield* restore(execute).pipe(
175
+ Effect.ensuring(
176
+ Effect.sync(() => {
177
+ __leaveFile();
178
+ setActiveRegistry(previous);
179
+ }),
180
+ ),
181
+ );
182
+ return registry;
183
+ }),
184
+ ),
185
+ );
186
+
187
+ /** A registry and its validated public projection. @internal */
188
+ export interface LoadedApp {
189
+ readonly registry: Registry;
190
+ readonly result: AppManifestResult;
191
+ }
192
+
193
+ /** Loads an app once and returns both its live registry and manifest. @internal */
194
+ export const loadApp = (
195
+ appDir: string,
196
+ ): Effect.Effect<LoadedApp, PublishError, Crypto.Crypto | FileSystem.FileSystem> =>
197
+ Effect.gen(function* () {
198
+ const fs = yield* FileSystem.FileSystem;
199
+ const files = (yield* fs.readDirectory(`${appDir}/tools`).pipe(
200
+ Effect.mapError((error) => new PublishError({ problems: [`tools/: ${error.message}`] })),
201
+ ))
202
+ .filter((file) => file.endsWith(".ts"))
203
+ .sort();
204
+ const registry = yield* executeModules(appDir, files);
205
+ const collected = collectRegistry(registry, compileInputSchema);
206
+ if (collected.problems.length > 0)
207
+ return yield* new PublishError({ problems: collected.problems });
208
+ if (!("manifest" in collected))
209
+ return yield* Effect.die("validated registry projection unexpectedly missing");
210
+
211
+ return {
212
+ registry,
213
+ result: {
214
+ digest: `snap:${yield* digestOf(collected.canonical)}`,
215
+ ...collected.manifest,
216
+ },
217
+ };
218
+ });
219
+
220
+ /** Executes an app and returns its deterministic publish manifest. */
221
+ export const manifest = (
222
+ appDir: string,
223
+ ): Effect.Effect<AppManifestResult, PublishError, Crypto.Crypto | FileSystem.FileSystem> =>
224
+ Effect.map(loadApp(appDir), ({ result }) => result);
@@ -0,0 +1,117 @@
1
+ import { Crypto, Effect, FileSystem } from "effect";
2
+ import type { ToolSpec } from "../authoring/index";
3
+ import {
4
+ loadApp,
5
+ type LoadedApp,
6
+ type PublishError,
7
+ } from "../manifest/index";
8
+ import { CallError, type AppRunner, type ToolCtx } from "./protocol";
9
+
10
+ export * from "./protocol";
11
+
12
+ interface StandardSchemaCapable {
13
+ readonly "~standard": {
14
+ readonly validate: (
15
+ value: unknown,
16
+ ) =>
17
+ | { readonly value: unknown; readonly issues?: undefined }
18
+ | { readonly issues: ReadonlyArray<{ readonly message: string }> }
19
+ | Promise<
20
+ | { readonly value: unknown; readonly issues?: undefined }
21
+ | { readonly issues: ReadonlyArray<{ readonly message: string }> }
22
+ >;
23
+ };
24
+ }
25
+
26
+ const hasStandardValidate = (input: object): input is StandardSchemaCapable =>
27
+ "~standard" in input &&
28
+ typeof (input as StandardSchemaCapable)["~standard"].validate === "function";
29
+
30
+ const parseInput = (name: string, spec: ToolSpec, input: unknown): Effect.Effect<unknown, CallError> => {
31
+ if (!hasStandardValidate(spec.input))
32
+ return Effect.fail(
33
+ new CallError({ target: name, reason: "input schema does not implement Standard Schema validate" }),
34
+ );
35
+ const standard = spec.input["~standard"];
36
+
37
+ return Effect.tryPromise({
38
+ try: () => Promise.resolve(standard.validate(input)),
39
+ catch: (error) =>
40
+ new CallError({
41
+ target: name,
42
+ reason: `input validation threw (${error instanceof Error ? error.message : String(error)})`,
43
+ }),
44
+ }).pipe(
45
+ Effect.flatMap((result) =>
46
+ result.issues === undefined
47
+ ? Effect.succeed(result.value)
48
+ : Effect.fail(
49
+ new CallError({
50
+ target: name,
51
+ reason: `invalid input (${result.issues.map((issue) => issue.message).join("; ")})`,
52
+ }),
53
+ ),
54
+ ),
55
+ );
56
+ };
57
+
58
+ /** Creates a permissive in-process runner for local development and tests. */
59
+ export const localRunner = (appDir: string): AppRunner => {
60
+ let cachedLoad: Effect.Effect<
61
+ LoadedApp,
62
+ PublishError,
63
+ Crypto.Crypto | FileSystem.FileSystem
64
+ > | undefined;
65
+
66
+ const loaded = (): Effect.Effect<
67
+ LoadedApp,
68
+ PublishError,
69
+ Crypto.Crypto | FileSystem.FileSystem
70
+ > =>
71
+ Effect.suspend(() => {
72
+ if (cachedLoad !== undefined) return cachedLoad;
73
+ return Effect.flatMap(Effect.cached(loadApp(appDir)), (effect) =>
74
+ Effect.sync(() => {
75
+ cachedLoad = effect;
76
+ return effect;
77
+ }).pipe(Effect.flatten),
78
+ );
79
+ });
80
+
81
+ const loadedFor = (
82
+ target: string,
83
+ ): Effect.Effect<LoadedApp, CallError, Crypto.Crypto | FileSystem.FileSystem> =>
84
+ loaded().pipe(
85
+ Effect.mapError(
86
+ (error) => new CallError({ target, reason: `app failed to load (${error.message})` }),
87
+ ),
88
+ );
89
+
90
+ return {
91
+ manifest: () => Effect.map(loaded(), ({ result }) => result),
92
+ call: (name, input, ctx) =>
93
+ Effect.gen(function* () {
94
+ const { registry } = yield* loadedFor(name);
95
+ const registered = registry.tools.get(name);
96
+ if (registered === undefined)
97
+ return yield* new CallError({ target: name, reason: "unknown tool" });
98
+ const parsed = yield* parseInput(name, registered.spec, input);
99
+ return yield* Effect.tryPromise({
100
+ // SAFETY: ToolSpec intentionally erases authored handler inputs to keep
101
+ // authoring generic-free; validation and ToolCtx establish this call boundary.
102
+ try: () =>
103
+ (registered.spec.handler as (value: unknown, toolCtx: ToolCtx) => Promise<unknown>)(
104
+ parsed,
105
+ ctx,
106
+ ),
107
+ catch: (error) =>
108
+ new CallError({
109
+ target: name,
110
+ reason: `handler failed (${error instanceof Error ? error.message : String(error)})`,
111
+ }),
112
+ });
113
+ }),
114
+ discover: (name, _binding) =>
115
+ Effect.fail(new CallError({ target: name, reason: "no dynamic tools yet" })),
116
+ };
117
+ };
@@ -0,0 +1,41 @@
1
+ import { Crypto, Data, type Effect, FileSystem } from "effect";
2
+ import type { AppManifestResult, PublishError } from "../manifest/contract";
3
+
4
+ /** The capability exposed to one tool requirement role. */
5
+ export interface ToolCtxEntry {
6
+ readonly grant: string;
7
+ readonly token?: string;
8
+ readonly secrets?: Readonly<Record<string, string>>;
9
+ fetch(path: string, init?: RequestInit): Promise<Response>;
10
+ }
11
+
12
+ /** Tool capabilities keyed by requirement role. */
13
+ export type ToolCtx = Readonly<Record<string, ToolCtxEntry>>;
14
+
15
+ /** A runner operation failure associated with its target. */
16
+ export class CallError extends Data.TaggedError("CallError")<{
17
+ readonly target: string;
18
+ readonly reason: string;
19
+ }> {
20
+ override get message(): string {
21
+ return `${this.target}: ${this.reason}`;
22
+ }
23
+ }
24
+
25
+ /** The platform protocol implemented by an app execution environment. */
26
+ export interface AppRunner {
27
+ manifest(): Effect.Effect<
28
+ AppManifestResult,
29
+ PublishError,
30
+ Crypto.Crypto | FileSystem.FileSystem
31
+ >;
32
+ call(
33
+ name: string,
34
+ input: unknown,
35
+ ctx: ToolCtx,
36
+ ): Effect.Effect<unknown, CallError, Crypto.Crypto | FileSystem.FileSystem>;
37
+ discover(
38
+ name: string,
39
+ binding: unknown,
40
+ ): Effect.Effect<unknown, CallError, Crypto.Crypto | FileSystem.FileSystem>;
41
+ }
@@ -0,0 +1,2 @@
1
+ /** Worker-safe registry projection injected into sandboxed app workers. @internal */
2
+ export { collectRegistry } from "../manifest/collect";
package/.npmignore DELETED
@@ -1 +0,0 @@
1
- node_modules
package/bin/apps DELETED
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- var program = require("commander")
4
- var pkg = require("../package.json")
5
- var marked = require('marked')
6
- var App = require("..")
7
-
8
- program
9
- .version(pkg.version)
10
-
11
- program
12
- .command("schema")
13
- .option('--markdown', 'Write the schema in markdown format')
14
- .option('--html', 'Write the schema in HTML format')
15
- .description("Write the app.json schema to STDOUT")
16
- .action(function(program){
17
- if (program.markdown) {
18
- process.stdout.write(App.templates.schema.render(App.schema))
19
- } else if (program.html) {
20
- process.stdout.write(marked(App.templates.schema.render(App.schema)))
21
- } else {
22
- process.stdout.write(JSON.stringify(App.schema.properties, null, 2))
23
- }
24
- })
25
-
26
- // Print this after the generated help text
27
- program.on("--help", function(){
28
- console.log(" Use 'app <command> --help' to get more information about a specific command.")
29
- console.log('')
30
- })
31
-
32
- program.parse(process.argv)
33
-
34
- if(program.args.length < 1){
35
- program.help()
36
- }
package/bin/build DELETED
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env bash
2
-
3
- echo "window.App = require('./')" \
4
- | node_modules/browserify/bin/cmd.js --transform browserify-hogan --transform brfs \
5
- > dist/app.js
6
-
7
- echo "Wrote dist/app.js"
8
-
9
- echo "window.App = require('./')" \
10
- | node_modules/browserify/bin/cmd.js --transform browserify-hogan --transform brfs \
11
- | node_modules/uglify-js/bin/uglifyjs \
12
- > dist/app.min.js
13
-
14
- echo "Wrote dist/app.min.js"
package/bin/doc DELETED
@@ -1,16 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- var fs = require("fs")
4
- var marked = require('marked')
5
- var App = require('..')
6
- var out = App.templates.schema.render(App.schema)
7
-
8
- fs.writeFile("./dist/schema.md", out, function(err) {
9
- if (err) return console.error(err)
10
- console.log("Wrote dist/schema.md")
11
-
12
- fs.writeFile("./dist/schema.html", marked(out), function(err) {
13
- if (err) return console.error(err)
14
- console.log("Wrote dist/schema.html")
15
- })
16
- })