okengine 0.2.8 → 0.3.2
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/AGENTS.md +17 -15
- package/README.md +57 -29
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +92 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +147 -0
- package/src/cli/openbao-bootstrap.ts +280 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +138 -0
- package/src/plugins/cors.ts +129 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +105 -0
- package/src/plugins/ip-allowlist.ts +76 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
package/src/kernel/hooks.ts
CHANGED
|
@@ -150,19 +150,34 @@ export interface PipelineResult {
|
|
|
150
150
|
readonly response: Response | undefined;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Serialize a pipeline outcome into an HTTP response (app-layer encoder). */
|
|
154
|
+
export type PipelineEncoder = (result: {
|
|
155
|
+
readonly response?: Response | undefined;
|
|
156
|
+
readonly failure?: FlowFailure | undefined;
|
|
157
|
+
readonly output?: unknown;
|
|
158
|
+
readonly error?: unknown;
|
|
159
|
+
}) => Response;
|
|
160
|
+
|
|
153
161
|
/**
|
|
154
162
|
* Run the seven-stage pipeline around `handler`.
|
|
155
163
|
*
|
|
164
|
+
* When `encode` is given (HTTP triggers), the outcome is serialized into
|
|
165
|
+
* `ctx.response` **before** `onResponse`, so the last stage sees — and may
|
|
166
|
+
* replace — the final response, as {@link InvocationContext.response}
|
|
167
|
+
* documents. Without `encode` (non-HTTP triggers) behavior is unchanged.
|
|
168
|
+
*
|
|
156
169
|
* @param ctx - Invocation context
|
|
157
170
|
* @param fx - Fx door
|
|
158
171
|
* @param hooks - Merged hooks
|
|
159
172
|
* @param handler - Flow body (the `handler` stage)
|
|
173
|
+
* @param encode - HTTP response encoder (e.g. `encodeExecuteResult`)
|
|
160
174
|
*/
|
|
161
175
|
export async function runPipeline(
|
|
162
176
|
ctx: InvocationContext,
|
|
163
177
|
fx: Fx,
|
|
164
178
|
hooks: HookMap,
|
|
165
179
|
handler: () => unknown | Promise<unknown>,
|
|
180
|
+
encode?: PipelineEncoder,
|
|
166
181
|
): Promise<PipelineResult> {
|
|
167
182
|
let shortCircuit: "response" | "error" | undefined;
|
|
168
183
|
|
|
@@ -215,6 +230,13 @@ export async function runPipeline(
|
|
|
215
230
|
await runStage("onError", hooks, ctx, fx);
|
|
216
231
|
}
|
|
217
232
|
|
|
233
|
+
// Serialize before the last stage so `onResponse` sees the final response
|
|
234
|
+
// (short-circuit wins; encoder input mirrors the app layer exactly).
|
|
235
|
+
if (encode !== undefined && ctx.response === undefined) {
|
|
236
|
+
const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
|
|
237
|
+
ctx.response = encode({ failure, output: ctx.result, error: ctx.error });
|
|
238
|
+
}
|
|
239
|
+
|
|
218
240
|
await runStage("onResponse", hooks, ctx, fx);
|
|
219
241
|
|
|
220
242
|
const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
|
package/src/kernel/index.ts
CHANGED
|
@@ -103,6 +103,8 @@ export {
|
|
|
103
103
|
export {
|
|
104
104
|
createFx,
|
|
105
105
|
createFxContext,
|
|
106
|
+
isJsonResult,
|
|
107
|
+
jsonResultBrand,
|
|
106
108
|
resolveName,
|
|
107
109
|
resolveStoreRef,
|
|
108
110
|
type CreateFxOptions,
|
|
@@ -113,12 +115,14 @@ export {
|
|
|
113
115
|
type FxCallHandler,
|
|
114
116
|
type FxClock,
|
|
115
117
|
type FxContext,
|
|
118
|
+
type FxJson,
|
|
116
119
|
type FxLog,
|
|
117
120
|
type FxOperator,
|
|
118
121
|
type FxSearchOptions,
|
|
119
122
|
type FxSendOptions,
|
|
120
123
|
type FxStoreHandle,
|
|
121
124
|
type FxTenant,
|
|
125
|
+
type JsonResult,
|
|
122
126
|
type NamedRef,
|
|
123
127
|
} from "./fx.ts";
|
|
124
128
|
|
|
@@ -236,6 +240,7 @@ export {
|
|
|
236
240
|
every,
|
|
237
241
|
http,
|
|
238
242
|
internal,
|
|
243
|
+
isResourceMount,
|
|
239
244
|
isSignalTriggerSource,
|
|
240
245
|
normalizeTrigger,
|
|
241
246
|
table,
|
|
@@ -246,6 +251,8 @@ export {
|
|
|
246
251
|
type HttpMethod,
|
|
247
252
|
type HttpTrigger,
|
|
248
253
|
type InternalTrigger,
|
|
254
|
+
type ResourceFlowBag,
|
|
255
|
+
type ResourceMount,
|
|
249
256
|
type SignalAsTrigger,
|
|
250
257
|
type SignalSource,
|
|
251
258
|
type TableHandle,
|
package/src/kernel/on.ts
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import { isFlow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "./flow.ts";
|
|
10
10
|
import {
|
|
11
|
+
isResourceMount,
|
|
11
12
|
normalizeTrigger,
|
|
12
13
|
type BoundTriggerOf,
|
|
14
|
+
type ResourceFlowBag,
|
|
15
|
+
type ResourceMount,
|
|
13
16
|
type SignalSource,
|
|
14
17
|
type Trigger,
|
|
15
18
|
} from "./triggers.ts";
|
|
@@ -39,17 +42,55 @@ export function on<
|
|
|
39
42
|
>(
|
|
40
43
|
trigger: T,
|
|
41
44
|
flowDef: FlowDef<I, O, E, D, Trigger | undefined>,
|
|
42
|
-
): FlowDef<I, O, E, D, BoundTriggerOf<T
|
|
45
|
+
): FlowDef<I, O, E, D, BoundTriggerOf<T>>;
|
|
46
|
+
/**
|
|
47
|
+
* Mount a CRUD resource (`http.resource(path, ops)`): registers the five
|
|
48
|
+
* verb bindings and returns the ops bag (unit keys `list` · `create` ·
|
|
49
|
+
* `get` · `update` · `remove`) for `.adopt({ notes })`.
|
|
50
|
+
*
|
|
51
|
+
* @param mount - Branded {@link ResourceMount}
|
|
52
|
+
*/
|
|
53
|
+
export function on(mount: ResourceMount): ResourceFlowBag;
|
|
54
|
+
export function on(
|
|
55
|
+
triggerOrMount: Trigger | SignalSource | ResourceMount,
|
|
56
|
+
flowDef?: FlowDef<any, any, any, any, Trigger | undefined>,
|
|
57
|
+
): unknown {
|
|
58
|
+
if (isResourceMount(triggerOrMount)) {
|
|
59
|
+
if (flowDef !== undefined) {
|
|
60
|
+
throw new TypeError("on(http.resource(...)) takes no second argument");
|
|
61
|
+
}
|
|
62
|
+
const byIdVerb: Partial<Record<string, "get" | "update" | "remove">> = {
|
|
63
|
+
GET: "get",
|
|
64
|
+
PATCH: "update",
|
|
65
|
+
DELETE: "remove",
|
|
66
|
+
};
|
|
67
|
+
const baseVerb: Partial<Record<string, "list" | "create">> = {
|
|
68
|
+
GET: "list",
|
|
69
|
+
POST: "create",
|
|
70
|
+
};
|
|
71
|
+
const ops: Record<string, unknown> = {};
|
|
72
|
+
for (const { trigger, flow } of triggerOrMount.mounts) {
|
|
73
|
+
const key = trigger.path.endsWith("/:id")
|
|
74
|
+
? byIdVerb[trigger.method]
|
|
75
|
+
: baseVerb[trigger.method];
|
|
76
|
+
if (key === undefined || !isFlow(flow)) {
|
|
77
|
+
throw new TypeError("on(http.resource(...)) expects the five CRUD FlowDefs");
|
|
78
|
+
}
|
|
79
|
+
on(trigger, flow as FlowDef<any, any, any, any, Trigger | undefined>);
|
|
80
|
+
ops[key] = flow;
|
|
81
|
+
}
|
|
82
|
+
return ops as unknown as ResourceFlowBag;
|
|
83
|
+
}
|
|
43
84
|
if (!isFlow(flowDef)) {
|
|
44
85
|
throw new TypeError("on() expected a flow() definition as the second argument");
|
|
45
86
|
}
|
|
46
|
-
const normalized = normalizeTrigger(
|
|
87
|
+
const normalized = normalizeTrigger(triggerOrMount as Trigger | SignalSource);
|
|
47
88
|
const list = flowDef.triggers as Trigger[];
|
|
48
89
|
list.push(normalized);
|
|
49
90
|
// Stamp runtime carrier for the first bound trigger (type follows BoundTriggerOf).
|
|
50
91
|
(flowDef as { $trigger: Trigger }).$trigger = normalized;
|
|
51
92
|
bindings.push({ trigger: normalized, flow: flowDef as AnyFlowDef });
|
|
52
|
-
return flowDef
|
|
93
|
+
return flowDef;
|
|
53
94
|
}
|
|
54
95
|
|
|
55
96
|
/**
|
package/src/kernel/plugin.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* and records exactly what was requested (capability capture). We never
|
|
7
7
|
* statically analyse plugin source to guess intent.
|
|
8
8
|
*
|
|
9
|
-
* Contributions: flows · hooks ·
|
|
10
|
-
*
|
|
11
|
-
* commands · Console panels.
|
|
9
|
+
* Contributions: flows · hooks · edge handlers (unmatched requests) ·
|
|
10
|
+
* context decorations · elements · drivers · image recipes · DB schema ·
|
|
11
|
+
* typed errors · client extensions · CLI commands · Console panels.
|
|
12
12
|
*
|
|
13
13
|
* @see docs/spec/unified-theory.md §14
|
|
14
14
|
* @see docs/spec/console.md §9.15
|
|
@@ -87,6 +87,17 @@ export interface ImageRecipeContribution {
|
|
|
87
87
|
readonly recipe: string;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Edge handler — runs for HTTP requests that match **no** flow (the prime
|
|
92
|
+
* example: a CORS preflight `OPTIONS` for a path bound to another method).
|
|
93
|
+
* Handlers run in install order; the first to return a {@link Response}
|
|
94
|
+
* answers, `undefined` passes to the next handler, then the plain 404.
|
|
95
|
+
*/
|
|
96
|
+
export type EdgeHandler = (
|
|
97
|
+
request: Request,
|
|
98
|
+
info: { readonly method: string; readonly path: string },
|
|
99
|
+
) => undefined | Response | Promise<undefined | Response>;
|
|
100
|
+
|
|
90
101
|
/** Table / DB schema contribution. */
|
|
91
102
|
export interface TableContribution {
|
|
92
103
|
/** Table name — conflict namespace shared across plugins. */
|
|
@@ -135,6 +146,12 @@ export interface PluginApi {
|
|
|
135
146
|
* @param fn - Hook function
|
|
136
147
|
*/
|
|
137
148
|
hook(stage: HookStage, fn: HookFn): PluginApi;
|
|
149
|
+
/**
|
|
150
|
+
* Register an edge handler (intercept — requests that match no flow).
|
|
151
|
+
*
|
|
152
|
+
* @param fn - Edge handler
|
|
153
|
+
*/
|
|
154
|
+
edge(fn: EdgeHandler): PluginApi;
|
|
138
155
|
/**
|
|
139
156
|
* Decorate the invocation context (declare — once at boot).
|
|
140
157
|
*
|
|
@@ -242,6 +259,7 @@ export interface PluginCapabilities {
|
|
|
242
259
|
export interface PluginRegistration {
|
|
243
260
|
readonly capabilities: PluginCapabilities;
|
|
244
261
|
readonly hooks: Partial<Record<HookStage, HookFn[]>>;
|
|
262
|
+
readonly edges: readonly EdgeHandler[];
|
|
245
263
|
readonly decorations: Readonly<Record<string, unknown>>;
|
|
246
264
|
readonly elements: readonly PluginElement[];
|
|
247
265
|
readonly tables: readonly TableContribution[];
|
|
@@ -285,6 +303,12 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
|
|
|
285
303
|
* @param fn - Hook function
|
|
286
304
|
*/
|
|
287
305
|
hook(stage: HookStage, fn: HookFn): PluginDef<D>;
|
|
306
|
+
/**
|
|
307
|
+
* Queue an edge handler contribution (requests that match no flow).
|
|
308
|
+
*
|
|
309
|
+
* @param fn - Edge handler
|
|
310
|
+
*/
|
|
311
|
+
edge(fn: EdgeHandler): PluginDef<D>;
|
|
288
312
|
/**
|
|
289
313
|
* Queue a context decoration. Accumulates types for `.plug()`.
|
|
290
314
|
*
|
|
@@ -398,6 +422,12 @@ export function plugin(name: string, options: PluginOptions): PluginDef {
|
|
|
398
422
|
});
|
|
399
423
|
return def;
|
|
400
424
|
},
|
|
425
|
+
edge(fn) {
|
|
426
|
+
steps.push((api) => {
|
|
427
|
+
api.edge(fn);
|
|
428
|
+
});
|
|
429
|
+
return def;
|
|
430
|
+
},
|
|
401
431
|
decorate(key, value) {
|
|
402
432
|
steps.push((api) => {
|
|
403
433
|
api.decorate(key, value);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry isolation — two `oke()` apps in one process must not share routes.
|
|
3
|
+
*
|
|
4
|
+
* This is the multi-app / future-`mount()` guarantee: constructing one app
|
|
5
|
+
* can never pull another app's flows through the process-global `on()`
|
|
6
|
+
* registry. Before the fix, `oke()` adopted the registry snapshot and left
|
|
7
|
+
* it intact, so App B below silently served App A's `/a` route (leakage).
|
|
8
|
+
* The default `registry: "consume"` mode drains the registry at construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { oke } from "./app.ts";
|
|
13
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
14
|
+
import { listBindings, on, resetBindings, type Binding } from "./on.ts";
|
|
15
|
+
import { http } from "./triggers.ts";
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetBindings();
|
|
19
|
+
resetFlowSeq();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("registry isolation", () => {
|
|
23
|
+
test("consume (default): App B does not serve App A routes", async () => {
|
|
24
|
+
on(http.get("/a"), flow({ name: "app-a.ping", do: () => "a" }));
|
|
25
|
+
const appA = oke({ name: "app-a" });
|
|
26
|
+
|
|
27
|
+
// No resetBindings() between the apps — the pre-fix leak scenario.
|
|
28
|
+
on(http.get("/b"), flow({ name: "app-b.ping", do: () => "b" }));
|
|
29
|
+
const appB = oke({ name: "app-b" });
|
|
30
|
+
|
|
31
|
+
// Pre-fix this was 200: App B adopted App A's leftover binding.
|
|
32
|
+
expect((await appB.fetch(new Request("http://localhost/a"))).status).toBe(404);
|
|
33
|
+
|
|
34
|
+
const own = await appB.fetch(new Request("http://localhost/b"));
|
|
35
|
+
expect(own.status).toBe(200);
|
|
36
|
+
expect(await own.json()).toEqual({ data: "b", error: null });
|
|
37
|
+
|
|
38
|
+
// App A still serves its own route after App B was constructed.
|
|
39
|
+
const stillA = await appA.fetch(new Request("http://localhost/a"));
|
|
40
|
+
expect(stillA.status).toBe(200);
|
|
41
|
+
expect(await stillA.json()).toEqual({ data: "a", error: null });
|
|
42
|
+
|
|
43
|
+
// Consume drained the registry — nothing left for a third app.
|
|
44
|
+
expect(listBindings()).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("keep: today's behavior — registry survives construction", async () => {
|
|
48
|
+
on(http.get("/a"), flow({ name: "kept.ping", do: () => "a" }));
|
|
49
|
+
oke({ name: "app-a", registry: "keep" });
|
|
50
|
+
expect(listBindings()).toHaveLength(1);
|
|
51
|
+
|
|
52
|
+
const appB = oke({ name: "app-b", registry: "keep" });
|
|
53
|
+
expect((await appB.fetch(new Request("http://localhost/a"))).status).toBe(200);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("ignore: only options.bindings are served", async () => {
|
|
57
|
+
on(http.get("/stray"), flow({ name: "stray.ping", do: () => "stray" }));
|
|
58
|
+
|
|
59
|
+
const onlyB: Binding = {
|
|
60
|
+
trigger: http.get("/b"),
|
|
61
|
+
flow: flow({ name: "only-b.ping", do: () => "b" }),
|
|
62
|
+
};
|
|
63
|
+
const app = oke({ name: "app-b", registry: "ignore", bindings: [onlyB] });
|
|
64
|
+
|
|
65
|
+
expect((await app.fetch(new Request("http://localhost/stray"))).status).toBe(404);
|
|
66
|
+
|
|
67
|
+
const res = await app.fetch(new Request("http://localhost/b"));
|
|
68
|
+
expect(res.status).toBe(200);
|
|
69
|
+
expect(await res.json()).toEqual({ data: "b", error: null });
|
|
70
|
+
|
|
71
|
+
// Ignore never touches the registry.
|
|
72
|
+
expect(listBindings()).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/kernel/registry.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ClientExtensionContribution,
|
|
21
21
|
ConsolePanelContribution,
|
|
22
22
|
DriverContribution,
|
|
23
|
+
EdgeHandler,
|
|
23
24
|
ImageRecipeContribution,
|
|
24
25
|
PluginApi,
|
|
25
26
|
PluginCapabilities,
|
|
@@ -62,6 +63,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
62
63
|
snapshot(): PluginRegistration;
|
|
63
64
|
} {
|
|
64
65
|
const hooks: Partial<Record<HookStage, HookFn[]>> = {};
|
|
66
|
+
const edges: EdgeHandler[] = [];
|
|
65
67
|
const decorations: Record<string, unknown> = {};
|
|
66
68
|
const elements: PluginElement[] = [];
|
|
67
69
|
const tables: TableContribution[] = [];
|
|
@@ -81,7 +83,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
81
83
|
if (!declares.includes(cap)) declares.push(cap);
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
function pushIntercept(stage: HookStage): void {
|
|
86
|
+
function pushIntercept(stage: HookStage | "edge"): void {
|
|
85
87
|
if (!intercepts.includes(stage)) intercepts.push(stage);
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -93,6 +95,11 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
93
95
|
pushIntercept(stage);
|
|
94
96
|
return api;
|
|
95
97
|
},
|
|
98
|
+
edge(fn) {
|
|
99
|
+
edges.push(fn);
|
|
100
|
+
pushIntercept("edge");
|
|
101
|
+
return api;
|
|
102
|
+
},
|
|
96
103
|
decorate(key, value) {
|
|
97
104
|
decorations[key] = value;
|
|
98
105
|
pushDeclare(`decorate:${key}`);
|
|
@@ -168,6 +175,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
168
175
|
needs: needs.slice(),
|
|
169
176
|
},
|
|
170
177
|
hooks: { ...hooks },
|
|
178
|
+
edges: edges.slice(),
|
|
171
179
|
decorations: { ...decorations },
|
|
172
180
|
elements: elements.slice(),
|
|
173
181
|
tables: tables.slice(),
|
|
@@ -231,6 +239,12 @@ export interface PluginRegistry {
|
|
|
231
239
|
* @param flowName - Flow name (for `flow` kind)
|
|
232
240
|
*/
|
|
233
241
|
hooksAt(scopeKind: PluginScope["kind"], unit: string | undefined, flowName: string): HookMap;
|
|
242
|
+
/**
|
|
243
|
+
* Edge handlers in install order. They answer requests that match no
|
|
244
|
+
* flow, so there is no flow/unit scope to filter by — every installed
|
|
245
|
+
* handler runs.
|
|
246
|
+
*/
|
|
247
|
+
edgeHandlers(): readonly EdgeHandler[];
|
|
234
248
|
/**
|
|
235
249
|
* Merged decorations visible to a flow at the given unit.
|
|
236
250
|
*
|
|
@@ -380,6 +394,13 @@ export function createPluginRegistry(): PluginRegistry {
|
|
|
380
394
|
}
|
|
381
395
|
return out;
|
|
382
396
|
},
|
|
397
|
+
edgeHandlers() {
|
|
398
|
+
const out: EdgeHandler[] = [];
|
|
399
|
+
for (const entry of installed) {
|
|
400
|
+
out.push(...entry.registration.edges);
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
},
|
|
383
404
|
tableContributions() {
|
|
384
405
|
const seenPlugins = new Set<string>();
|
|
385
406
|
const out: TableContribution[] = [];
|
package/src/kernel/triggers.ts
CHANGED
|
@@ -100,6 +100,59 @@ function createHttpTrigger<M extends HttpMethod, P extends string>(
|
|
|
100
100
|
return trigger;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Flow shape accepted by {@link http.resource} (duck-typed — any FlowDef). */
|
|
104
|
+
export type ResourceFlow = { readonly name: string };
|
|
105
|
+
|
|
106
|
+
/** The five CRUD ops {@link http.resource} mounts. */
|
|
107
|
+
export interface ResourceFlowBag {
|
|
108
|
+
readonly list: unknown;
|
|
109
|
+
readonly create: unknown;
|
|
110
|
+
readonly get: unknown;
|
|
111
|
+
readonly update: unknown;
|
|
112
|
+
readonly remove: unknown;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A mounted resource — the single argument to the `on(http.resource(…))`
|
|
117
|
+
* overload. Branded so `on` can tell it apart from a plain trigger.
|
|
118
|
+
*/
|
|
119
|
+
export interface ResourceMount {
|
|
120
|
+
readonly [resourceMountBrand]: true;
|
|
121
|
+
readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
|
|
122
|
+
}
|
|
123
|
+
/** Brand for {@link ResourceMount}. */
|
|
124
|
+
export const resourceMountBrand: unique symbol = Symbol.for("oke.resource.mount");
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Mount a CRUD resource at `path`: `list`/`create` on the base, `get` /
|
|
128
|
+
* `update` / `remove` on `/:id`. Bind via `on(http.resource(…))`.
|
|
129
|
+
*
|
|
130
|
+
* @param path - Base path (`/notes`)
|
|
131
|
+
* @param ops - The five FlowDefs (usually `resource.all()`)
|
|
132
|
+
*/
|
|
133
|
+
function httpResource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount {
|
|
134
|
+
const id = `${path}/:id`;
|
|
135
|
+
return {
|
|
136
|
+
[resourceMountBrand]: true,
|
|
137
|
+
mounts: [
|
|
138
|
+
{ trigger: http.get(path), flow: ops.list },
|
|
139
|
+
{ trigger: http.post(path), flow: ops.create },
|
|
140
|
+
{ trigger: http.get(id), flow: ops.get },
|
|
141
|
+
{ trigger: http.patch(id), flow: ops.update },
|
|
142
|
+
{ trigger: http.delete(id), flow: ops.remove },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when `value` is a {@link ResourceMount}. */
|
|
148
|
+
export function isResourceMount(value: unknown): value is ResourceMount {
|
|
149
|
+
return (
|
|
150
|
+
typeof value === "object" &&
|
|
151
|
+
value !== null &&
|
|
152
|
+
(value as ResourceMount)[resourceMountBrand] === true
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
103
156
|
/**
|
|
104
157
|
* Shape of the {@link http} trigger namespace. Each method keeps `P` as a
|
|
105
158
|
* generic type parameter so callers (and the client) retain literal path
|
|
@@ -134,6 +187,11 @@ export interface HttpTriggerNamespace {
|
|
|
134
187
|
* @param path - Route path
|
|
135
188
|
*/
|
|
136
189
|
head<P extends string>(path: P): HttpTrigger<"HEAD", P>;
|
|
190
|
+
/**
|
|
191
|
+
* Mount a CRUD resource (list/create on `path`, get/update/remove on
|
|
192
|
+
* `path/:id`) for the `on(http.resource(…))` overload.
|
|
193
|
+
*/
|
|
194
|
+
resource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount;
|
|
137
195
|
}
|
|
138
196
|
|
|
139
197
|
/**
|
|
@@ -182,6 +240,7 @@ export const http: HttpTriggerNamespace = {
|
|
|
182
240
|
head<P extends string>(path: P): HttpTrigger<"HEAD", P> {
|
|
183
241
|
return createHttpTrigger("HEAD", path);
|
|
184
242
|
},
|
|
243
|
+
resource: httpResource,
|
|
185
244
|
};
|
|
186
245
|
|
|
187
246
|
/**
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"journeys": { "book-a-flight": { "slo": { "availability": "99.5%" }, "composes": "99.6%" } },
|
|
49
|
-
"drivers": { "prod": ["postgres", "redis", "s3", "smtp", "
|
|
49
|
+
"drivers": { "prod": ["postgres", "redis", "s3", "smtp", "openbao", "anthropic", "pgvector"] },
|
|
50
50
|
"tenancy": { "isolation": "row" }
|
|
51
51
|
}
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
139
|
"drivers": {
|
|
140
|
-
"prod": ["postgres", "redis", "s3", "smtp", "
|
|
140
|
+
"prod": ["postgres", "redis", "s3", "smtp", "openbao", "anthropic", "pgvector"]
|
|
141
141
|
},
|
|
142
142
|
"tenancy": { "isolation": "row" },
|
|
143
143
|
"i18n": {
|
package/src/manifest/index.ts
CHANGED
package/src/manifest/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TypeScript surface matching `
|
|
2
|
+
* TypeScript surface matching `manifest.v1.schema.json`.
|
|
3
3
|
*
|
|
4
4
|
* Kept hand-aligned with the schema (the public, runtime-neutral contract).
|
|
5
5
|
* Within major version `1.x`, only additive schema changes are allowed.
|
package/src/manifest/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime validation of Manifest documents against `
|
|
2
|
+
* Runtime validation of Manifest documents against `manifest.v1.schema.json`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import Ajv2020, { type AnySchema, type ErrorObject } from "ajv/dist/2020.js";
|
|
@@ -19,7 +19,7 @@ export type ManifestValidationResult =
|
|
|
19
19
|
| { ok: true; manifest: Manifest }
|
|
20
20
|
| { ok: false; issues: ManifestValidationIssue[] };
|
|
21
21
|
|
|
22
|
-
const SCHEMA_URL = new URL("../../
|
|
22
|
+
const SCHEMA_URL = new URL("../../manifest.v1.schema.json", import.meta.url);
|
|
23
23
|
|
|
24
24
|
let validator: ReturnType<Ajv2020["compile"]> | undefined;
|
|
25
25
|
let schemaDocument: unknown;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `compression` plugin — gzip negotiation, thresholds, and skip rules
|
|
3
|
+
* through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { acceptsGzip, compression } from "./compression.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Big JSON payload above the default 1 KiB threshold. */
|
|
19
|
+
function bigPayload() {
|
|
20
|
+
return { items: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `item-${i}` })) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("acceptsGzip", () => {
|
|
24
|
+
test("parses plain, starred, and q-valued tokens", () => {
|
|
25
|
+
expect(acceptsGzip(null)).toBe(false);
|
|
26
|
+
expect(acceptsGzip("")).toBe(false);
|
|
27
|
+
expect(acceptsGzip("gzip")).toBe(true);
|
|
28
|
+
expect(acceptsGzip("br, gzip")).toBe(true);
|
|
29
|
+
expect(acceptsGzip("gzip;q=0")).toBe(false);
|
|
30
|
+
expect(acceptsGzip("gzip;q=0.0")).toBe(false);
|
|
31
|
+
expect(acceptsGzip("gzip;q=0.5")).toBe(true);
|
|
32
|
+
expect(acceptsGzip("*")).toBe(true);
|
|
33
|
+
expect(acceptsGzip("*;q=0")).toBe(false);
|
|
34
|
+
expect(acceptsGzip("br")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("compression plugin", () => {
|
|
39
|
+
test("gzips a large JSON body when the client accepts gzip", async () => {
|
|
40
|
+
on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
|
|
41
|
+
const app = oke({ name: "zip" }).plug(compression());
|
|
42
|
+
|
|
43
|
+
const res = await app.fetch(
|
|
44
|
+
new Request("http://localhost/big", { headers: { "accept-encoding": "gzip" } }),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(res.headers.get("content-encoding")).toBe("gzip");
|
|
49
|
+
expect(res.headers.get("vary")).toContain("accept-encoding");
|
|
50
|
+
expect(res.headers.get("content-length")).toBeNull();
|
|
51
|
+
|
|
52
|
+
const roundTrip = JSON.parse(
|
|
53
|
+
new TextDecoder().decode(Bun.gunzipSync(await res.arrayBuffer())),
|
|
54
|
+
) as {
|
|
55
|
+
data: { items: unknown[] };
|
|
56
|
+
};
|
|
57
|
+
expect(roundTrip.data.items).toHaveLength(100);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("passes through untouched without Accept-Encoding: gzip", async () => {
|
|
61
|
+
on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
|
|
62
|
+
const app = oke({ name: "zip-plain" }).plug(compression());
|
|
63
|
+
|
|
64
|
+
const res = await app.fetch(new Request("http://localhost/big"));
|
|
65
|
+
|
|
66
|
+
expect(res.headers.get("content-encoding")).toBeNull();
|
|
67
|
+
const body = (await res.json()) as { data: { items: unknown[] } };
|
|
68
|
+
expect(body.data.items).toHaveLength(100);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("skips bodies under minSize", async () => {
|
|
72
|
+
on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
|
|
73
|
+
const app = oke({ name: "zip-small" }).plug(compression());
|
|
74
|
+
|
|
75
|
+
const res = await app.fetch(
|
|
76
|
+
new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(res.headers.get("content-encoding")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("minSize: 0 compresses even tiny bodies", async () => {
|
|
83
|
+
on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
|
|
84
|
+
const app = oke({ name: "zip-zero" }).plug(compression({ minSize: 0 }));
|
|
85
|
+
|
|
86
|
+
const res = await app.fetch(
|
|
87
|
+
new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(res.headers.get("content-encoding")).toBe("gzip");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("skips non-matching content types and no-transform responses", async () => {
|
|
94
|
+
on(
|
|
95
|
+
http.get("/img"),
|
|
96
|
+
flow({
|
|
97
|
+
name: "img.get",
|
|
98
|
+
do: () =>
|
|
99
|
+
new Response(new Uint8Array(4096), {
|
|
100
|
+
headers: { "content-type": "image/png" },
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
on(
|
|
105
|
+
http.get("/nt"),
|
|
106
|
+
flow({
|
|
107
|
+
name: "nt.get",
|
|
108
|
+
do: () =>
|
|
109
|
+
Response.json(
|
|
110
|
+
{ data: bigPayload(), error: null },
|
|
111
|
+
{ headers: { "cache-control": "no-transform" } },
|
|
112
|
+
),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
const app = oke({ name: "zip-skip" }).plug(compression());
|
|
116
|
+
|
|
117
|
+
const img = await app.fetch(
|
|
118
|
+
new Request("http://localhost/img", { headers: { "accept-encoding": "gzip" } }),
|
|
119
|
+
);
|
|
120
|
+
expect(img.headers.get("content-encoding")).toBeNull();
|
|
121
|
+
|
|
122
|
+
const nt = await app.fetch(
|
|
123
|
+
new Request("http://localhost/nt", { headers: { "accept-encoding": "gzip" } }),
|
|
124
|
+
);
|
|
125
|
+
expect(nt.headers.get("content-encoding")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|