acrylblends 0.0.0-stage → 0.3.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 (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
@@ -0,0 +1,105 @@
1
+ /** The standalone adapter over real sockets: serving, connection facts, port scan, streams, disposal, reload. */
2
+ import { createServer } from 'node:http'
3
+ import type { AddressInfo } from 'node:net'
4
+ import { Context } from '@deepseek-ai/cordis'
5
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
6
+ import { streamSSE } from '../../acryl-router/src/helpers/index.ts'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import * as nodeServer from '../src/index.ts'
9
+
10
+ /** The plugin as an object, so its config is the partial `apply` accepts rather than the Loader's validated shape. */
11
+ const plugin = { name: nodeServer.name, inject: nodeServer.inject, apply: nodeServer.apply }
12
+ const contexts: Context[] = []
13
+ afterEach(async () => {
14
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
15
+ })
16
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 20))
17
+
18
+ async function boot(config: Partial<nodeServer.Config> = {}): Promise<Context> {
19
+ const ctx = new Context()
20
+ contexts.push(ctx)
21
+ ctx.plugin({ name: routerName, apply: routerApply })
22
+ ctx.plugin(plugin, config)
23
+ await settle()
24
+ return ctx
25
+ }
26
+
27
+ /** Hold a port so a server asked for it has to scan. */
28
+ async function occupy(): Promise<{ port: number, release: () => Promise<void> }> {
29
+ const blocker = createServer()
30
+ await new Promise<void>(resolve => blocker.listen(0, '127.0.0.1', resolve))
31
+ return { port: (blocker.address() as AddressInfo).port, release: () => new Promise<void>(resolve => blocker.close(() => { resolve() })) }
32
+ }
33
+
34
+ const portIsFree = async (port: number): Promise<boolean> => {
35
+ const probe = createServer()
36
+ return new Promise<boolean>(resolve => {
37
+ probe.once('error', () => { resolve(false) })
38
+ probe.listen(port, '127.0.0.1', () => { probe.close(() => { resolve(true) }) })
39
+ })
40
+ }
41
+
42
+ describe('acryl-node-server', () => {
43
+ it('serves the router over a real socket with the peer and port as connection facts', async () => {
44
+ const ctx = await boot()
45
+ ctx.router.get('/who', c => c.json({ peer: c.connection.remoteAddress, port: c.connection.localPort }))
46
+ const body = await (await fetch(`${ctx.nodeServer.url}/who`)).json() as { peer: string, port: number }
47
+ expect(ctx.nodeServer.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/u)
48
+ expect(body.port).toBe(ctx.nodeServer.port)
49
+ expect(body.peer).toMatch(/127\.0\.0\.1/u)
50
+ expect((await fetch(`${ctx.nodeServer.url}/missing`)).status).toBe(404)
51
+ })
52
+
53
+ it('scans to the next free port when the configured one is taken', async () => {
54
+ const taken = await occupy()
55
+ try {
56
+ const ctx = await boot({ port: taken.port, scan: 20 })
57
+ expect(ctx.nodeServer.port).toBeGreaterThan(taken.port)
58
+ expect(ctx.nodeServer.port).toBeLessThanOrEqual(taken.port + 20)
59
+ } finally {
60
+ await taken.release()
61
+ }
62
+ })
63
+
64
+ it('fails the plugin when no port in the scan range is free', async () => {
65
+ const taken = await occupy()
66
+ try {
67
+ const ctx = new Context()
68
+ contexts.push(ctx)
69
+ ctx.plugin({ name: routerName, apply: routerApply })
70
+ const fiber = ctx.plugin(plugin, { port: taken.port, scan: 0 })
71
+ await settle()
72
+ expect(fiber.state).toBe(3) // FAILED
73
+ } finally {
74
+ await taken.release()
75
+ }
76
+ })
77
+
78
+ it('on dispose ends open event streams and frees the port', async () => {
79
+ const ctx = await boot()
80
+ ctx.router.get('/events', c => streamSSE(c, stream => { stream.send('hello') }))
81
+ const port = ctx.nodeServer.port
82
+ const response = await fetch(`${ctx.nodeServer.url}/events`)
83
+ const reader = response.body!.getReader()
84
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain('data: hello')
85
+ await ctx.fiber.dispose()
86
+ contexts.splice(contexts.indexOf(ctx), 1)
87
+ const ended = await reader.read().then(result => result.done, () => true)
88
+ expect(ended).toBe(true)
89
+ expect(await portIsFree(port)).toBe(true)
90
+ })
91
+
92
+ it('reloads without leaking a listener', async () => {
93
+ const ctx = new Context()
94
+ contexts.push(ctx)
95
+ ctx.plugin({ name: routerName, apply: routerApply })
96
+ const ports: number[] = []
97
+ for (let round = 0; round < 5; round += 1) {
98
+ const fiber = ctx.plugin(plugin, {})
99
+ await settle()
100
+ ports.push(ctx.nodeServer.port)
101
+ await fiber.dispose()
102
+ }
103
+ for (const port of ports) expect(await portIsFree(port)).toBe(true)
104
+ })
105
+ })
@@ -0,0 +1,18 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-node-server",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 4,
13
+ "digest": "sha256:0931c522f7b595d80dd39a4d6179d8ba05acef612f309985149f2501c2c4622e",
14
+ "testsNotRun": {},
15
+ "notice": [
16
+ "LICENSE"
17
+ ]
18
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "acryl-router",
3
+ "description": "The router service as a Cordis plugin: Web-standard Request and Response, path parameters, middleware, helpers, an in-process fetch and request() for tests. Vendored from ACRYL.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": ["router"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@deepseek-ai/schemastery": "3.18.4"
10
+ },
11
+ "mount": "root.plugin(router); then in your own plugin: inject: ['router'] and ctx.router.get('/path', (c) => c.json({ ok: true }))"
12
+ }
@@ -0,0 +1,21 @@
1
+ import type { Handler, Middleware, RouteContext } from './types.ts'
2
+
3
+ /**
4
+ * Run `middleware` in order around `final`. Each middleware receives `next`, which runs the rest of the chain and
5
+ * resolves to its response. Calling `next` twice in one middleware is a programming error and rejects.
6
+ */
7
+ export function compose(middleware: readonly Middleware[], final: Handler): Handler {
8
+ return (c: RouteContext): Promise<Response> => {
9
+ const dispatch = async (index: number): Promise<Response> => {
10
+ const current = middleware[index]
11
+ if (current === undefined) return final(c)
12
+ let called = false
13
+ return current(c, () => {
14
+ if (called) return Promise.reject(new Error('router: next() called more than once'))
15
+ called = true
16
+ return dispatch(index + 1)
17
+ })
18
+ }
19
+ return dispatch(0)
20
+ }
21
+ }
@@ -0,0 +1,59 @@
1
+ import type { ConnectionInfo, ResponseBody, RouteContext } from './types.ts'
2
+
3
+ const JSON_TYPE = 'application/json; charset=utf-8'
4
+ const TEXT_TYPE = 'text/plain; charset=utf-8'
5
+ const HTML_TYPE = 'text/html; charset=utf-8'
6
+
7
+ /** Statuses that must not carry a body; a `Response` constructed with one throws. */
8
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304])
9
+ const bodyFor = <T>(status: number, body: T): T | null => NULL_BODY_STATUSES.has(status) ? null : body
10
+
11
+ /** Build the per-request context. `notFound` is what `c.notFound()` runs: the router's own answer. */
12
+ export function createContext(
13
+ req: Request,
14
+ params: Record<string, string>,
15
+ signal: AbortSignal,
16
+ connection: ConnectionInfo,
17
+ track: (task: Promise<unknown>) => void,
18
+ notFound: (c: RouteContext) => Response | Promise<Response>,
19
+ ): RouteContext {
20
+ const values = new Map<string, unknown>()
21
+ const url = new URL(req.url)
22
+ const defaults = new Headers()
23
+ let defaultStatus = 200
24
+
25
+ const respond = (body: ResponseBody, status: number | undefined, type: string | undefined, extra?: Record<string, string>): Response => {
26
+ const code = status ?? defaultStatus
27
+ const headers = new Headers(defaults)
28
+ if (type !== undefined && !headers.has('content-type')) headers.set('content-type', type)
29
+ for (const [name, value] of Object.entries(extra ?? {})) headers.set(name, value)
30
+ return new Response(bodyFor(code, body), { status: code, headers })
31
+ }
32
+
33
+ const context: RouteContext = {
34
+ req,
35
+ url,
36
+ params,
37
+ connection,
38
+ signal,
39
+ waitUntil: track,
40
+ get: <T>(key: string) => values.get(key) as T | undefined,
41
+ set: (key, value) => { values.set(key, value) },
42
+ header: (name, value) => { defaults.set(name, value) },
43
+ status: code => { defaultStatus = code },
44
+ query: ((name?: string) => {
45
+ if (name !== undefined) return url.searchParams.get(name) ?? undefined
46
+ const all: Record<string, string> = {}
47
+ for (const [key, value] of url.searchParams) if (!(key in all)) all[key] = value
48
+ return all
49
+ }) as RouteContext['query'],
50
+ param: ((name?: string) => name === undefined ? params : params[name]) as RouteContext['param'],
51
+ json: (data, status) => respond(JSON.stringify(data), status, JSON_TYPE),
52
+ text: (body, status) => respond(body, status, TEXT_TYPE),
53
+ html: (body, status) => respond(body, status, HTML_TYPE),
54
+ body: (data, status, headers) => respond(data, status, undefined, headers),
55
+ redirect: (location, status = 302) => respond(null, status, undefined, { location }),
56
+ notFound: () => notFound(context),
57
+ }
58
+ return context
59
+ }
@@ -0,0 +1,3 @@
1
+ export { readBytes, readJson, readText, type ReadBodyOptions } from './read-body.ts'
2
+ export { streamSSE, type SseOptions, type SseStream } from './sse.ts'
3
+ export { valid, validate, type StandardSchema, type ValidationTarget } from './validate.ts'
@@ -0,0 +1,59 @@
1
+ import { HttpError } from '../http-error.ts'
2
+ import { BODY_LIMIT_KEY } from '../middleware/body-limit.ts'
3
+ import type { RouteContext } from '../types.ts'
4
+
5
+ export interface ReadBodyOptions {
6
+ /** The most bytes to accept. Defaults to the `bodyLimit` middleware's limit when it ran, otherwise 1 MiB. */
7
+ readonly maxBytes?: number
8
+ }
9
+
10
+ const DEFAULT_MAX_BYTES = 1024 * 1024
11
+
12
+ /**
13
+ * Read the request body as bytes, counting as it streams, and stop at the limit (a 413 `HttpError`). Stops at once when the request is aborted.
14
+ * A request with no body gives an empty array.
15
+ */
16
+ export async function readBytes(c: RouteContext, options: ReadBodyOptions = {}): Promise<Uint8Array> {
17
+ const limit = options.maxBytes ?? c.get<number>(BODY_LIMIT_KEY) ?? DEFAULT_MAX_BYTES
18
+ c.signal.throwIfAborted()
19
+ const reader = c.req.body?.getReader()
20
+ if (reader === undefined) return new Uint8Array()
21
+ const onAbort = (): void => { void reader.cancel(c.signal.reason).catch(() => undefined) }
22
+ c.signal.addEventListener('abort', onAbort, { once: true })
23
+ const chunks: Uint8Array[] = []
24
+ let size = 0
25
+ try {
26
+ for (;;) {
27
+ const { done, value } = await reader.read()
28
+ if (done) break
29
+ size += value.byteLength
30
+ if (size > limit) {
31
+ await reader.cancel().catch(() => undefined)
32
+ throw new HttpError(413, { message: 'payload too large' })
33
+ }
34
+ chunks.push(value)
35
+ }
36
+ } finally {
37
+ c.signal.removeEventListener('abort', onAbort)
38
+ }
39
+ c.signal.throwIfAborted()
40
+ const bytes = new Uint8Array(size)
41
+ let offset = 0
42
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
43
+ return bytes
44
+ }
45
+
46
+ /** The body as UTF-8 text, within the limit. */
47
+ export async function readText(c: RouteContext, options?: ReadBodyOptions): Promise<string> {
48
+ return new TextDecoder().decode(await readBytes(c, options))
49
+ }
50
+
51
+ /** The body parsed as JSON, within the limit. Bad JSON is a 400 `HttpError`. */
52
+ export async function readJson(c: RouteContext, options?: ReadBodyOptions): Promise<unknown> {
53
+ const text = await readText(c, options)
54
+ try {
55
+ return JSON.parse(text) as unknown
56
+ } catch {
57
+ throw new HttpError(400, { message: 'invalid json' })
58
+ }
59
+ }
@@ -0,0 +1,83 @@
1
+ import type { RouteContext } from '../types.ts'
2
+
3
+ export interface SseStream {
4
+ /** Send one event. Objects and arrays are sent as JSON; strings as they are. */
5
+ send(data: unknown, options?: { readonly event?: string, readonly id?: string }): void
6
+ /** Send a comment line (a keep-alive that clients ignore). */
7
+ comment(text: string): void
8
+ /** End the stream from the server side. */
9
+ close(): void
10
+ /** Aborted when the client goes away or the router is disposed. */
11
+ readonly signal: AbortSignal
12
+ }
13
+
14
+ const HEADERS = {
15
+ 'content-type': 'text/event-stream; charset=utf-8',
16
+ 'cache-control': 'no-store',
17
+ 'x-content-type-options': 'nosniff',
18
+ } as const
19
+
20
+ const encode = (text: string): Uint8Array => new TextEncoder().encode(text)
21
+
22
+ export interface SseOptions {
23
+ /** A comment line is sent this often so proxies and browsers keep an idle stream open. Default 15 seconds. */
24
+ readonly keepAliveMs?: number
25
+ }
26
+
27
+ const DEFAULT_KEEP_ALIVE_MS = 15_000
28
+
29
+ /**
30
+ * A server-sent-event response. `run` starts the stream; whatever cleanup function it returns runs exactly once when the stream
31
+ * ends for any reason (the client disconnects, `close()`, a plugin unload, the router being disposed). The request is held in flight with `waitUntil`
32
+ * until then, so disposal waits for the stream instead of leaking it.
33
+ */
34
+ export function streamSSE(c: RouteContext, run: (stream: SseStream) => void | (() => void), options: SseOptions = {}): Response {
35
+ let finish!: () => void
36
+ c.waitUntil(new Promise<void>(resolve => { finish = resolve }))
37
+ let controller!: ReadableStreamDefaultController<Uint8Array>
38
+ let cleanup: (() => void) | undefined
39
+ let ended = false
40
+ let keepAlive: ReturnType<typeof setInterval> | undefined
41
+
42
+ const end = (): void => {
43
+ if (ended) return
44
+ ended = true
45
+ clearInterval(keepAlive)
46
+ c.signal.removeEventListener('abort', end)
47
+ try { cleanup?.() } finally {
48
+ try { controller.close() } catch { /* already closed */ }
49
+ finish()
50
+ }
51
+ }
52
+
53
+ const body = new ReadableStream<Uint8Array>({
54
+ start: ctrl => {
55
+ controller = ctrl
56
+ const stream: SseStream = {
57
+ signal: c.signal,
58
+ send: (data, options) => {
59
+ if (ended) return
60
+ const lines: string[] = []
61
+ if (options?.id !== undefined) lines.push(`id: ${options.id}`)
62
+ if (options?.event !== undefined) lines.push(`event: ${options.event}`)
63
+ const payload = typeof data === 'string' ? data : JSON.stringify(data)
64
+ for (const line of payload.split('\n')) lines.push(`data: ${line}`)
65
+ try { controller.enqueue(encode(`${lines.join('\n')}\n\n`)) } catch { end() }
66
+ },
67
+ comment: text => {
68
+ if (ended) return
69
+ try { controller.enqueue(encode(`: ${text.replaceAll('\n', ' ')}\n\n`)) } catch { end() }
70
+ },
71
+ close: end,
72
+ }
73
+ if (c.signal.aborted) { end(); return }
74
+ c.signal.addEventListener('abort', end, { once: true })
75
+ keepAlive = setInterval(() => { stream.comment('keepalive') }, options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS)
76
+ keepAlive.unref()
77
+ cleanup = run(stream) ?? undefined
78
+ if (ended) { try { cleanup?.() } catch { /* the stream already ended */ } }
79
+ },
80
+ cancel: end,
81
+ })
82
+ return new Response(body, { headers: HEADERS })
83
+ }
@@ -0,0 +1,44 @@
1
+ import { HttpError } from '../http-error.ts'
2
+ import type { Middleware, RouteContext } from '../types.ts'
3
+ import { readJson } from './read-body.ts'
4
+
5
+ /** The slice of the Standard Schema interface this needs; Schemastery (what Cordis uses), Zod and Valibot all implement it. */
6
+ export interface StandardSchema<T = unknown> {
7
+ readonly '~standard': {
8
+ validate(value: unknown): StandardResult<T> | Promise<StandardResult<T>>
9
+ }
10
+ }
11
+
12
+ type StandardResult<T> =
13
+ | { readonly value: T, readonly issues?: undefined }
14
+ | { readonly issues: readonly { readonly message: string, readonly path?: readonly unknown[] | undefined }[] }
15
+
16
+ export type ValidationTarget = 'json' | 'query' | 'param'
17
+
18
+ const key = (target: ValidationTarget): string => `valid:${target}`
19
+
20
+ /**
21
+ * Validate part of the request against a schema before the handler runs. A failure is a 400 with the issues; success stores the
22
+ * parsed value, which the handler reads with `valid(c, target)`. `json` reads the body within the limit; `query` is the first value of each query
23
+ * parameter; `param` is the matched path parameters.
24
+ */
25
+ export function validate<T>(target: ValidationTarget, schema: StandardSchema<T>): Middleware {
26
+ return async (c, next) => {
27
+ const input = target === 'json' ? await readJson(c) : target === 'query' ? c.query() : { ...c.param() }
28
+ const result = await schema['~standard'].validate(input)
29
+ if (result.issues !== undefined) {
30
+ throw new HttpError(400, {
31
+ response: c.json({ error: `invalid ${target}`, issues: result.issues.map(issue => ({ message: issue.message, path: (issue.path ?? []).map(part => String(typeof part === 'object' && part !== null && 'key' in part ? (part as { key: unknown }).key : part)).join('.') })) }, 400),
32
+ })
33
+ }
34
+ c.set(key(target), result.value)
35
+ return next()
36
+ }
37
+ }
38
+
39
+ /** The value `validate(target, schema)` stored. Throws if that middleware did not run on this route. */
40
+ export function valid<T>(c: RouteContext, target: ValidationTarget): T {
41
+ const value = c.get<T>(key(target))
42
+ if (value === undefined) throw new Error(`valid("${target}"): no validate("${target}", schema) ran before this handler`)
43
+ return value
44
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * An error that carries the response it should become. A handler or middleware throws it to stop a request
3
+ * with a status: `throw new HttpError(401, { message: 'Sign in first' })`. The router answers with `getResponse()`; a registered
4
+ * `onError` handler still sees every other error.
5
+ */
6
+ export class HttpError extends Error {
7
+ readonly status: number
8
+ private readonly response: Response | undefined
9
+
10
+ constructor(status: number, options: { readonly message?: string, readonly response?: Response, readonly cause?: unknown } = {}) {
11
+ super(options.message ?? `HTTP ${String(status)}`, options.cause === undefined ? undefined : { cause: options.cause })
12
+ this.name = 'HttpError'
13
+ this.status = status
14
+ this.response = options.response
15
+ }
16
+
17
+ /** The response this error stands for: the one it was given, or its message as plain text with its status. */
18
+ getResponse(): Response {
19
+ return this.response ?? new Response(this.message, { status: this.status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ export { Router, Config, apply, name, type RouterHttpService } from './service.ts'
2
+ export { RouteTable, type RouteTableOptions } from './table.ts'
3
+ export { compose } from './compose.ts'
4
+ export { HttpError } from './http-error.ts'
5
+ export type { Registrar, RouteHandlers, UseArguments } from './registrar.ts'
6
+ export type { ConnectionInfo, ErrorHandler, NotFoundHandler, ResponseBody, Handler, Method, Middleware, Next, RouteContext, RouteMethod, TrailingSlash } from './types.ts'
@@ -0,0 +1,25 @@
1
+ import type { Middleware, RouteContext } from '../types.ts'
2
+
3
+ export interface BodyLimitOptions {
4
+ readonly maxBytes: number
5
+ /** The response for a body that is too large. Defaults to a 413 with a small JSON body. */
6
+ readonly onError?: (c: RouteContext) => Response | Promise<Response>
7
+ }
8
+
9
+ /** The context key under which the limit is kept for the body-reading helpers. */
10
+ export const BODY_LIMIT_KEY = 'bodyLimit'
11
+
12
+ /**
13
+ * Refuse a request whose declared `content-length` is over the limit, and remember the limit so `readJson` and `readText` enforce it on the bytes
14
+ * they read (a chunked body declares no length). A request body is a stream: only the helpers, or a handler that counts, can stop it midway.
15
+ */
16
+ export function bodyLimit(options: BodyLimitOptions): Middleware {
17
+ return async (c, next) => {
18
+ const declared = Number(c.req.headers.get('content-length'))
19
+ if (Number.isFinite(declared) && declared > options.maxBytes) {
20
+ return options.onError === undefined ? c.json({ error: 'payload too large', maxBytes: options.maxBytes }, 413) : options.onError(c)
21
+ }
22
+ c.set(BODY_LIMIT_KEY, options.maxBytes)
23
+ return next()
24
+ }
25
+ }
@@ -0,0 +1,46 @@
1
+ import type { Middleware } from '../types.ts'
2
+ import { appendVary, setHeaders } from '../util.ts'
3
+
4
+ export interface CorsOptions {
5
+ /** `'*'`, one origin, a list, or a function that returns the origin to allow (or `undefined` to refuse). */
6
+ readonly origin?: string | readonly string[] | ((origin: string) => string | undefined)
7
+ readonly allowMethods?: readonly string[]
8
+ readonly allowHeaders?: readonly string[]
9
+ readonly exposeHeaders?: readonly string[]
10
+ readonly maxAge?: number
11
+ readonly credentials?: boolean
12
+ }
13
+
14
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']
15
+
16
+ /** Cross-origin resource sharing, with preflight answered here (a 204 that never reaches the route). */
17
+ export function cors(options: CorsOptions = {}): Middleware {
18
+ const allowed = (origin: string): string | undefined => {
19
+ const rule = options.origin ?? '*'
20
+ if (typeof rule === 'function') return rule(origin)
21
+ if (typeof rule === 'string') return rule === '*' || rule === origin ? rule : undefined
22
+ return rule.includes(origin) ? origin : undefined
23
+ }
24
+ return async (c, next) => {
25
+ const origin = c.req.headers.get('origin')
26
+ const match = origin === null ? undefined : allowed(origin)
27
+ const shared: Record<string, string> = {}
28
+ if (match !== undefined) shared['access-control-allow-origin'] = match
29
+ if (options.credentials === true && match !== undefined) shared['access-control-allow-credentials'] = 'true'
30
+ if (options.exposeHeaders !== undefined && options.exposeHeaders.length > 0) shared['access-control-expose-headers'] = options.exposeHeaders.join(',')
31
+
32
+ if (c.req.method === 'OPTIONS' && c.req.headers.has('access-control-request-method')) {
33
+ const headers = { ...shared }
34
+ if (match !== undefined) {
35
+ headers['access-control-allow-methods'] = (options.allowMethods ?? DEFAULT_METHODS).join(',')
36
+ const requested = c.req.headers.get('access-control-request-headers')
37
+ const allowHeaders = options.allowHeaders ?? (requested === null ? [] : requested.split(',').map(part => part.trim()))
38
+ if (allowHeaders.length > 0) headers['access-control-allow-headers'] = allowHeaders.join(',')
39
+ if (options.maxAge !== undefined) headers['access-control-max-age'] = String(options.maxAge)
40
+ }
41
+ return appendVary(c.body(null, 204, headers), 'Origin')
42
+ }
43
+ const response = await next()
44
+ return appendVary(setHeaders(response, shared), 'Origin')
45
+ }
46
+ }
@@ -0,0 +1,6 @@
1
+ export { bodyLimit, BODY_LIMIT_KEY, type BodyLimitOptions } from './body-limit.ts'
2
+ export { cors, type CorsOptions } from './cors.ts'
3
+ export { logger, type LogSink } from './logger.ts'
4
+ export { loopbackAuthority, loopbackOnly, sameOrigin, type LoopbackOptions } from './loopback-only.ts'
5
+ export { requestId, type RequestIdOptions } from './request-id.ts'
6
+ export { secureHeaders, type SecureHeadersOptions } from './secure-headers.ts'
@@ -0,0 +1,21 @@
1
+ import type { Middleware } from '../types.ts'
2
+
3
+ /** One line to a sink the caller owns, for example `line => ctx.logger.info(line)`. */
4
+ export type LogSink = (line: string) => void
5
+
6
+ /** Log each request on the way in and its status and time on the way out. The sink is required: a framework does not write to the console on its own. */
7
+ export function logger(sink: LogSink): Middleware {
8
+ return async (c, next) => {
9
+ const label = `${c.req.method} ${c.url.pathname}`
10
+ sink(`<-- ${label}`)
11
+ const started = performance.now()
12
+ try {
13
+ const response = await next()
14
+ sink(`--> ${label} ${String(response.status)} ${String(Math.round(performance.now() - started))}ms`)
15
+ return response
16
+ } catch (error) {
17
+ sink(`--> ${label} threw ${error instanceof Error ? error.message : String(error)} ${String(Math.round(performance.now() - started))}ms`)
18
+ throw error
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,82 @@
1
+ import { BlockList, isIP } from 'node:net'
2
+ import type { Middleware, RouteContext } from '../types.ts'
3
+
4
+ export interface LoopbackOptions {
5
+ /** Hostnames a `Host` header may name. Default: the loopback names `127.0.0.1`, `localhost` and `[::1]`. */
6
+ readonly hostnames?: readonly string[]
7
+ /**
8
+ * What a request that changes things (not GET, HEAD or OPTIONS) must show: `same-origin` (default) refuses an `Origin` header that is not this same
9
+ * authority (including `null`), which stops another web page from driving a local app. A browser always sends `Origin` on a cross-origin change, so a
10
+ * request with no `Origin` (curl, a script) is allowed: the `Host` it names is already checked to be a loopback address with this server's port, which
11
+ * is what stops DNS rebinding. `none` checks only the peer and the `Host`.
12
+ */
13
+ readonly mutations?: 'same-origin' | 'none'
14
+ /**
15
+ * A call made inside the process (the terminal UI, a test) has no socket, so no peer to check. `deny` (default) refuses it, the safe choice for a route
16
+ * that guards something; `allow` lets it through for routes whose in-process caller is the host's own code. A request that came over a socket is always
17
+ * checked in full: only a call with no connection facts at all counts as in process.
18
+ */
19
+ readonly inProcess?: 'deny' | 'allow'
20
+ }
21
+
22
+ const loopback = new BlockList()
23
+ loopback.addSubnet('127.0.0.0', 8, 'ipv4')
24
+ loopback.addSubnet('::1', 128, 'ipv6')
25
+
26
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
27
+
28
+ /**
29
+ * The authority a loopback request addressed: the `Host` header as a URL, or `undefined` when the request does not come from a loopback peer to the
30
+ * real local port, names another host, or is marked cross-site by the browser. The peer and the port come from the transport (`c.connection`), never
31
+ * from a header; an in-process call has neither and is refused.
32
+ */
33
+ export const LOOPBACK_HOSTNAMES: readonly string[] = ['127.0.0.1', 'localhost', '[::1]']
34
+
35
+ export function loopbackAuthority(c: RouteContext, hostnames: readonly string[] = LOOPBACK_HOSTNAMES): URL | undefined {
36
+ const { remoteAddress, localPort } = c.connection
37
+ const host = c.req.headers.get('host')
38
+ if (remoteAddress === undefined || localPort === undefined || host === null) return undefined
39
+ const address = (remoteAddress.replace(/^\[|\]$/gu, '').split('%', 1)[0] ?? '').replace(/^::ffff:/iu, '')
40
+ const family = isIP(address)
41
+ if (family === 0 || !loopback.check(address, family === 4 ? 'ipv4' : 'ipv6')) return undefined
42
+ let authority: URL
43
+ try {
44
+ authority = new URL(`http://${host}`)
45
+ } catch {
46
+ return undefined
47
+ }
48
+ if (
49
+ authority.protocol !== 'http:'
50
+ || Number(authority.port || '80') !== localPort
51
+ || !hostnames.includes(authority.hostname)
52
+ || c.req.headers.get('sec-fetch-site') === 'cross-site'
53
+ ) return undefined
54
+ return authority
55
+ }
56
+
57
+ /** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
58
+ export function sameOrigin(c: RouteContext, authority: URL): boolean {
59
+ const origin = c.req.headers.get('origin')
60
+ if (origin === null) return true
61
+ try {
62
+ const parsed = new URL(origin)
63
+ return parsed.protocol === 'http:' && parsed.host === authority.host && parsed.pathname === '/'
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ /** Only requests from this machine to this app: loopback peer, the real port, an allowed `Host`, and for mutations a matching `Origin`. Everything else gets a 403. */
70
+ export function loopbackOnly(options: LoopbackOptions = {}): Middleware {
71
+ const mutations = options.mutations ?? 'same-origin'
72
+ const inProcess = options.inProcess ?? 'deny'
73
+ return async (c, next) => {
74
+ if (inProcess === 'allow' && c.connection.remoteAddress === undefined && c.connection.localPort === undefined) return next()
75
+ const authority = loopbackAuthority(c, options.hostnames)
76
+ if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
77
+ if (mutations === 'same-origin' && !SAFE_METHODS.has(c.req.method) && !sameOrigin(c, authority)) {
78
+ return c.json({ error: 'a change must come from this app\'s own origin' }, 403)
79
+ }
80
+ return next()
81
+ }
82
+ }