acrylblends 0.7.8 → 0.8.1
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/dist/index.d.ts +13 -5
- package/dist/index.js +72 -13
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +2 -1
- package/docs/framework/upstream.json +3 -3
- package/lib/node-server.js +1 -0
- package/package.json +1 -1
- package/plugins/acryl-db/plugin.json +6 -1
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +6 -1
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/resolve.ts +16 -2
- package/plugins/tsconfig.json +6 -0
- package/shared/DEFAULTS.md +11 -6
- package/shared/SKILL.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +18 -2
- package/templates/agent/src/routes.ts +14 -1
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +10 -4
- package/templates/agent/test/app.test.ts +29 -8
- package/templates/agent/test/builder.test.ts +34 -23
- package/templates/agent/test/model.test.ts +24 -19
- package/templates/blank/src/app.ts +25 -5
- package/templates/blank/test/app.test.ts +28 -9
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +32 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +107 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +12 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +29 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +95 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +11 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +32 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +107 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +12 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/src/app.ts +27 -7
- package/templates/todo/test/todo.test.ts +47 -22
- package/templates/todo/upstream.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shape of a door call, shared by the Host and the in-page driver (moved from `acryl-agent-control`, spec 041).
|
|
3
|
+
*
|
|
4
|
+
* The Host turns a verb's arguments into a {@link UiRequest}, sends it to the page over the channel, and gets a {@link UiResult} back.
|
|
5
|
+
* Everything crossing the channel is validated here once, so neither side trusts the other's JSON. The page imports this file too, so it
|
|
6
|
+
* lives with the page code and has no Node imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The operations the driver performs on the page. */
|
|
10
|
+
export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait'] as const
|
|
11
|
+
export type UiOp = (typeof UI_OPS)[number]
|
|
12
|
+
|
|
13
|
+
/** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
|
|
14
|
+
export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press']
|
|
15
|
+
|
|
16
|
+
export const MAX_TEXT_LENGTH = 10_000
|
|
17
|
+
export const MAX_WAIT_MS = 10_000
|
|
18
|
+
export const MAX_SNAPSHOT_NODES = 1000
|
|
19
|
+
export const DEFAULT_SNAPSHOT_NODES = 300
|
|
20
|
+
|
|
21
|
+
/** Why a call did not do what was asked. Every failure is one of these, never a silent wrong action. */
|
|
22
|
+
export type UiErrorCode =
|
|
23
|
+
| 'no-window' // no page of the app is connected
|
|
24
|
+
| 'stale-ref' // the element changed or was replaced since the snapshot
|
|
25
|
+
| 'unknown-ref' // the ref was never issued
|
|
26
|
+
| 'sensitive' // a password, token, API key or payment field
|
|
27
|
+
| 'protected' // a control the agent may never operate (policy, approval, this plugin)
|
|
28
|
+
| 'not-actionable' // hidden, disabled, or the wrong kind of element for the action
|
|
29
|
+
| 'not-found' // a wait ended without the thing appearing
|
|
30
|
+
| 'killed' // the user pressed the kill switch
|
|
31
|
+
| 'timeout' // the page did not answer in time
|
|
32
|
+
| 'aborted' // the call was cancelled
|
|
33
|
+
| 'unloaded' // the driver went away mid-call
|
|
34
|
+
| 'disconnected' // the page's channel dropped while the call was in flight
|
|
35
|
+
| 'invalid' // malformed request
|
|
36
|
+
|
|
37
|
+
export class UiControlError extends Error {
|
|
38
|
+
readonly code: UiErrorCode
|
|
39
|
+
|
|
40
|
+
constructor(code: UiErrorCode, message: string) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.code = code
|
|
43
|
+
this.name = 'UiControlError'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One element in a snapshot. */
|
|
48
|
+
export interface UiNode {
|
|
49
|
+
/** `<generation>.<n>`; valid only until the next snapshot. */
|
|
50
|
+
readonly ref: string
|
|
51
|
+
readonly role: string
|
|
52
|
+
readonly name: string
|
|
53
|
+
readonly depth: number
|
|
54
|
+
/** Short state words: `disabled`, `checked`, `expanded`, `selected`, `focused`, `protected`, ... */
|
|
55
|
+
readonly states: readonly string[]
|
|
56
|
+
/** The text of an editable field, when it is not sensitive. */
|
|
57
|
+
readonly value?: string
|
|
58
|
+
/** Heading level, when it is a heading. */
|
|
59
|
+
readonly level?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UiSnapshot {
|
|
63
|
+
readonly generation: number
|
|
64
|
+
readonly title: string
|
|
65
|
+
readonly nodes: readonly UiNode[]
|
|
66
|
+
/** How many elements matched in all; more than `nodes.length` when the page was cut. */
|
|
67
|
+
readonly total: number
|
|
68
|
+
/** Pass to the next snapshot call to continue, or absent when this is everything. */
|
|
69
|
+
readonly nextCursor?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type UiRequest =
|
|
73
|
+
| { readonly op: 'snapshot'; readonly cursor?: number; readonly maxNodes?: number }
|
|
74
|
+
| { readonly op: 'click'; readonly ref: string }
|
|
75
|
+
| { readonly op: 'type'; readonly ref: string; readonly text: string; readonly submit?: boolean; readonly clear?: boolean }
|
|
76
|
+
| { readonly op: 'select'; readonly ref: string; readonly option: string }
|
|
77
|
+
| { readonly op: 'press'; readonly key: string; readonly ref?: string }
|
|
78
|
+
| { readonly op: 'scroll'; readonly direction: 'up' | 'down' | 'left' | 'right'; readonly ref?: string; readonly amount?: number }
|
|
79
|
+
| { readonly op: 'wait'; readonly text?: string; readonly role?: string; readonly name?: string; readonly gone?: boolean; readonly timeoutMs?: number }
|
|
80
|
+
|
|
81
|
+
/** What the page did, and to which control (for the audit log and the approval prompt). */
|
|
82
|
+
export interface UiActionResult {
|
|
83
|
+
readonly ok: true
|
|
84
|
+
readonly target?: { readonly role: string; readonly name: string }
|
|
85
|
+
readonly detail?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type UiResult = UiSnapshot | UiActionResult
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Messages the page and the Host exchange over the channel. The Host sends calls down the page's event stream (`event: call`); the page
|
|
92
|
+
* opens that stream with its window id and focus, and sends everything else up by POST, naming its window id.
|
|
93
|
+
*/
|
|
94
|
+
export type ChannelToPage =
|
|
95
|
+
| { readonly t: 'call'; readonly id: number; readonly request: UiRequest }
|
|
96
|
+
export type ChannelToHost =
|
|
97
|
+
| { readonly t: 'focus'; readonly focused: boolean }
|
|
98
|
+
| { readonly t: 'result'; readonly id: number; readonly value: UiResult }
|
|
99
|
+
| { readonly t: 'error'; readonly id: number; readonly code: UiErrorCode; readonly message: string }
|
|
100
|
+
|
|
101
|
+
const KEYS = /^(?:[A-Za-z0-9]|Enter|Escape|Tab|Backspace|Delete|Space|Arrow(?:Up|Down|Left|Right)|Home|End|PageUp|PageDown)$/
|
|
102
|
+
const REF = /^\d{1,9}\.\d{1,6}$/
|
|
103
|
+
|
|
104
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
105
|
+
const invalid = (message: string): never => { throw new UiControlError('invalid', message) }
|
|
106
|
+
|
|
107
|
+
function ref(value: unknown): string {
|
|
108
|
+
if (typeof value !== 'string' || !REF.test(value)) return invalid('ref must look like 3.17, from the latest snapshot')
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function text(value: unknown, max = MAX_TEXT_LENGTH): string {
|
|
113
|
+
if (typeof value !== 'string' || value.length > max || value.includes('\0')) return invalid(`text must be a string of at most ${String(max)} characters`)
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param value - untrusted JSON. @throws UiControlError `invalid`. */
|
|
118
|
+
export function parseUiRequest(value: unknown): UiRequest {
|
|
119
|
+
if (!isRecord(value) || typeof value.op !== 'string') return invalid('a call needs an op')
|
|
120
|
+
const op = value.op as UiOp
|
|
121
|
+
const only = (allowed: readonly string[]): void => {
|
|
122
|
+
const extra = Object.keys(value).find(key => key !== 'op' && !allowed.includes(key))
|
|
123
|
+
if (extra !== undefined) invalid(`unknown field ${extra}`)
|
|
124
|
+
}
|
|
125
|
+
switch (op) {
|
|
126
|
+
case 'snapshot': {
|
|
127
|
+
only(['cursor', 'maxNodes'])
|
|
128
|
+
const cursor = value.cursor === undefined ? undefined : Number(value.cursor)
|
|
129
|
+
const maxNodes = value.maxNodes === undefined ? undefined : Number(value.maxNodes)
|
|
130
|
+
if ((cursor !== undefined && (!Number.isInteger(cursor) || cursor < 0)) || (maxNodes !== undefined && (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > MAX_SNAPSHOT_NODES))) return invalid('cursor and maxNodes must be small positive integers')
|
|
131
|
+
return { op, ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) }
|
|
132
|
+
}
|
|
133
|
+
case 'click': only(['ref']); return { op, ref: ref(value.ref) }
|
|
134
|
+
case 'type': {
|
|
135
|
+
only(['ref', 'text', 'submit', 'clear'])
|
|
136
|
+
if ((value.submit !== undefined && typeof value.submit !== 'boolean') || (value.clear !== undefined && typeof value.clear !== 'boolean')) return invalid('submit and clear must be true or false')
|
|
137
|
+
return { op, ref: ref(value.ref), text: text(value.text), ...(value.submit === undefined ? {} : { submit: value.submit }), ...(value.clear === undefined ? {} : { clear: value.clear }) }
|
|
138
|
+
}
|
|
139
|
+
case 'select': only(['ref', 'option']); return { op, ref: ref(value.ref), option: text(value.option, 500) }
|
|
140
|
+
case 'press': {
|
|
141
|
+
only(['key', 'ref'])
|
|
142
|
+
if (typeof value.key !== 'string' || !KEYS.test(value.key)) return invalid('key must be a single character or a name such as Enter, Escape, Tab or ArrowDown')
|
|
143
|
+
return { op, key: value.key, ...(value.ref === undefined ? {} : { ref: ref(value.ref) }) }
|
|
144
|
+
}
|
|
145
|
+
case 'scroll': {
|
|
146
|
+
only(['direction', 'ref', 'amount'])
|
|
147
|
+
if (value.direction !== 'up' && value.direction !== 'down' && value.direction !== 'left' && value.direction !== 'right') return invalid('direction must be up, down, left or right')
|
|
148
|
+
const amount = value.amount === undefined ? undefined : Number(value.amount)
|
|
149
|
+
if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0 || amount > 10_000)) return invalid('amount must be between 1 and 10000 pixels')
|
|
150
|
+
return { op, direction: value.direction, ...(value.ref === undefined ? {} : { ref: ref(value.ref) }), ...(amount === undefined ? {} : { amount }) }
|
|
151
|
+
}
|
|
152
|
+
case 'wait': {
|
|
153
|
+
only(['text', 'role', 'name', 'gone', 'timeoutMs'])
|
|
154
|
+
const timeoutMs = value.timeoutMs === undefined ? undefined : Number(value.timeoutMs)
|
|
155
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_WAIT_MS)) return invalid(`timeoutMs must be between 0 and ${String(MAX_WAIT_MS)}`)
|
|
156
|
+
if (value.gone !== undefined && typeof value.gone !== 'boolean') return invalid('gone must be true or false')
|
|
157
|
+
if (value.text === undefined && value.role === undefined && value.name === undefined) return invalid('wait needs text, or a role and name')
|
|
158
|
+
return {
|
|
159
|
+
op,
|
|
160
|
+
...(value.text === undefined ? {} : { text: text(value.text, 500) }),
|
|
161
|
+
...(value.role === undefined ? {} : { role: text(value.role, 60) }),
|
|
162
|
+
...(value.name === undefined ? {} : { name: text(value.name, 500) }),
|
|
163
|
+
...(value.gone === undefined ? {} : { gone: value.gone }),
|
|
164
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
default: return invalid(`unknown op ${String(op)}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param text - one `call` event's data from the Host. @throws when it is not a well-formed call. */
|
|
172
|
+
export function parseChannelToPage(text: string): ChannelToPage {
|
|
173
|
+
const value: unknown = JSON.parse(text)
|
|
174
|
+
if (isRecord(value) && value.t === 'call' && typeof value.id === 'number' && Number.isSafeInteger(value.id)) {
|
|
175
|
+
return { t: 'call', id: value.id, request: parseUiRequest(value.request) }
|
|
176
|
+
}
|
|
177
|
+
throw new UiControlError('invalid', 'not a channel call')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every code a {@link UiControlError} can carry, so a code read off the wire is validated, not cast. */
|
|
181
|
+
export const UI_ERROR_CODES: readonly UiErrorCode[] = ['no-window', 'stale-ref', 'unknown-ref', 'sensitive', 'protected', 'not-actionable', 'not-found', 'killed', 'timeout', 'aborted', 'unloaded', 'disconnected', 'invalid']
|
|
182
|
+
|
|
183
|
+
/** A window id the page chose: short, printable, so it can travel in a query string and a log line. */
|
|
184
|
+
export const WINDOW_ID = /^[A-Za-z0-9-]{1,80}$/u
|
|
185
|
+
|
|
186
|
+
/** @param value - one parsed message from the page; null when it is not a well-formed message. */
|
|
187
|
+
export function parseChannelToHost(value: unknown): ChannelToHost | null {
|
|
188
|
+
if (!isRecord(value)) return null
|
|
189
|
+
if (value.t === 'focus' && typeof value.focused === 'boolean') return { t: 'focus', focused: value.focused }
|
|
190
|
+
if (value.t === 'result' && typeof value.id === 'number' && isRecord(value.value)) return { t: 'result', id: value.id, value: value.value as unknown as UiResult }
|
|
191
|
+
if (value.t === 'error' && typeof value.id === 'number' && typeof value.message === 'string' && UI_ERROR_CODES.includes(value.code as UiErrorCode)) {
|
|
192
|
+
return { t: 'error', id: value.id, code: value.code as UiErrorCode, message: value.message.slice(0, 500) }
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One line of the audit log: what the agent did, never what it typed. */
|
|
198
|
+
export interface AuditEntry {
|
|
199
|
+
/** ISO time of the call. */
|
|
200
|
+
readonly at: string
|
|
201
|
+
readonly tool: string
|
|
202
|
+
/** The control touched, when the call had one. */
|
|
203
|
+
readonly target?: { readonly role: string; readonly name: string }
|
|
204
|
+
readonly outcome: 'ok' | 'refused' | 'failed'
|
|
205
|
+
/** The error code for a refusal or failure, or a short note for a success. */
|
|
206
|
+
readonly detail?: string
|
|
207
|
+
/**
|
|
208
|
+
* Whether the user was asked first (`asked`), approval was switched off (`none`), the call needed no
|
|
209
|
+
* approval (`not-needed`), or it came from an operator holding this run's secret (`token`, spec 041 TB30): authorized by the secret,
|
|
210
|
+
* which only the OS user who started the app can read, and watched by the person at the page through the indicator and its kill switch.
|
|
211
|
+
*/
|
|
212
|
+
readonly approval: 'asked' | 'none' | 'not-needed' | 'token'
|
|
213
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** How a snapshot reads to an agent. Pure text, shared by the Host and the tests (no DOM). */
|
|
2
|
+
|
|
3
|
+
import type { UiSnapshot } from './protocol.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The snapshot as compact text for an agent: one line per element, indented by depth.
|
|
7
|
+
* @param more - how to ask for the rest when the page was cut, given the cursor to continue from.
|
|
8
|
+
*/
|
|
9
|
+
export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => string = cursor => `call ui_snapshot with cursor ${String(cursor)}`): string {
|
|
10
|
+
const lines = snapshot.nodes.map((node) => {
|
|
11
|
+
const level = node.level === undefined ? '' : ` level=${String(node.level)}`
|
|
12
|
+
const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
|
|
13
|
+
const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
|
|
14
|
+
return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value} [ref=${node.ref}]`
|
|
15
|
+
})
|
|
16
|
+
const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
|
|
17
|
+
return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Tracks when the user last used the page, so the agent yields to them. */
|
|
2
|
+
|
|
3
|
+
/** Events only a real person produces: a script's `dispatchEvent` is never `isTrusted`. */
|
|
4
|
+
const USER_EVENTS = ['pointerdown', 'mousedown', 'keydown', 'wheel', 'touchstart'] as const
|
|
5
|
+
|
|
6
|
+
export class UserInputClock {
|
|
7
|
+
private last = Number.NEGATIVE_INFINITY
|
|
8
|
+
|
|
9
|
+
private readonly now: () => number
|
|
10
|
+
|
|
11
|
+
constructor(now: () => number = () => Date.now()) {
|
|
12
|
+
this.now = now
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Milliseconds since the user last touched the page; Infinity when they have not. */
|
|
16
|
+
msSince = (): number => (this.last === Number.NEGATIVE_INFINITY ? Number.POSITIVE_INFINITY : this.now() - this.last)
|
|
17
|
+
|
|
18
|
+
/** Start listening. @returns disposer. */
|
|
19
|
+
attach(target: Document): () => void {
|
|
20
|
+
const handler = (event: Event): void => { if (event.isTrusted) this.last = this.now() }
|
|
21
|
+
for (const type of USER_EVENTS) target.addEventListener(type, handler, { capture: true, passive: true })
|
|
22
|
+
return () => { for (const type of USER_EVENTS) target.removeEventListener(type, handler, { capture: true }) }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-door",
|
|
5
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 13,
|
|
13
|
+
"digest": "sha256:90f0731ebb2e6d8a7c29e6f1e6f250cb2eb719b6622e5d9e5655281947584d6c",
|
|
14
|
+
"testsNotRun": {
|
|
15
|
+
"tests/page/accessible-name-hidden.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
16
|
+
"tests/page/channel.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
17
|
+
"tests/page/driver.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
18
|
+
"tests/page/indicator.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
19
|
+
"tests/page/visible-text.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
20
|
+
"tests/host/door.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
21
|
+
"tests/host/verbs.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
22
|
+
"tests/host/shim.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
|
|
23
|
+
"tests/host/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install"
|
|
24
|
+
},
|
|
25
|
+
"notice": [
|
|
26
|
+
"LICENSE"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -8,5 +8,10 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4"
|
|
9
9
|
},
|
|
10
10
|
"mount": "it is the port, not a service: a plugin declares inject: ['kv'] and imports the ctx.kv type from '../plugins/acryl-kv/src/cordis.ts'; add acryl-kv-json (a file) or acryl-kv-db (your SQL database) to provide it",
|
|
11
|
-
"omit": ["
|
|
11
|
+
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
12
|
+
"imports": {
|
|
13
|
+
"#acryl-kv": "acryl-kv/src/index.ts",
|
|
14
|
+
"#acryl-kv/conformance": "acryl-kv/src/conformance.ts"
|
|
15
|
+
},
|
|
16
|
+
"suite": "src/conformance.ts"
|
|
12
17
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `kv` conformance suite: what every provider of the port must do, as Vitest tests. A provider passes it to be accepted
|
|
3
|
+
* (`describeKvConformance('acryl-kv-json', open)` in its own tests), so the same cases run on a file and on SQL.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import type { Kv } from './kv.ts'
|
|
8
|
+
|
|
9
|
+
/** A fresh, empty store from the provider under test. */
|
|
10
|
+
export interface KvUnderTest {
|
|
11
|
+
readonly kv: Kv
|
|
12
|
+
close(): Promise<void>
|
|
13
|
+
/** Close this store (if it is still open) and open the same storage again, to prove what was written is durable. */
|
|
14
|
+
reopen(): Promise<KvUnderTest>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function describeKvConformance(provider: string, open: () => Promise<KvUnderTest>): void {
|
|
18
|
+
describe(`kv conformance: ${provider}`, () => {
|
|
19
|
+
const opened: KvUnderTest[] = []
|
|
20
|
+
const track = (subject: KvUnderTest): KvUnderTest => {
|
|
21
|
+
opened.push(subject)
|
|
22
|
+
return subject
|
|
23
|
+
}
|
|
24
|
+
const fresh = async (): Promise<Kv> => track(await open()).kv
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await Promise.all(opened.splice(0).map(subject => subject.close()))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('round-trips null, booleans, numbers, text with unicode, arrays and nested objects', async () => {
|
|
30
|
+
const kv = await fresh()
|
|
31
|
+
const values: unknown[] = [null, true, false, 0, -1.5, 9_007_199_254_740_991, '', 'café ✓ 😀', [], [1, [2, [3]]], {}, { a: { b: [1, { c: null }] }, 'ключ': 'значение' }]
|
|
32
|
+
for (const [index, value] of values.entries()) {
|
|
33
|
+
await kv.set(`value/${index}`, value)
|
|
34
|
+
expect(await kv.get(`value/${index}`), JSON.stringify(value)).toEqual(value)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('answers get with undefined for a missing key, and overwrites on set', async () => {
|
|
39
|
+
const kv = await fresh()
|
|
40
|
+
expect(await kv.get('missing')).toBeUndefined()
|
|
41
|
+
await kv.set('k', { n: 1 })
|
|
42
|
+
await kv.set('k', { n: 2 })
|
|
43
|
+
expect(await kv.get('k')).toEqual({ n: 2 })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('deletes a key, and a missing key is not an error', async () => {
|
|
47
|
+
const kv = await fresh()
|
|
48
|
+
await kv.set('k', 1)
|
|
49
|
+
await kv.delete('k')
|
|
50
|
+
expect(await kv.get('k')).toBeUndefined()
|
|
51
|
+
await expect(kv.delete('k')).resolves.toBeUndefined()
|
|
52
|
+
await expect(kv.delete('never-existed')).resolves.toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stores a null value as null, apart from a missing key', async () => {
|
|
56
|
+
const kv = await fresh()
|
|
57
|
+
await kv.set('k', null)
|
|
58
|
+
expect(await kv.get('k')).toBeNull()
|
|
59
|
+
expect(await kv.list()).toEqual(['k'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('lists keys sorted by code point, with an optional prefix', async () => {
|
|
63
|
+
const kv = await fresh()
|
|
64
|
+
for (const key of ['b', 'a/2', 'a/10', 'a/1', 'ab', 'B', 'a']) await kv.set(key, key)
|
|
65
|
+
expect(await kv.list()).toEqual(['B', 'a', 'a/1', 'a/10', 'a/2', 'ab', 'b'])
|
|
66
|
+
expect(await kv.list('a/')).toEqual(['a/1', 'a/10', 'a/2'])
|
|
67
|
+
expect(await kv.list('a')).toEqual(['a', 'a/1', 'a/10', 'a/2', 'ab'])
|
|
68
|
+
expect(await kv.list('')).toEqual(['B', 'a', 'a/1', 'a/10', 'a/2', 'ab', 'b'])
|
|
69
|
+
expect(await kv.list('nothing')).toEqual([])
|
|
70
|
+
expect(await kv.list(undefined)).toEqual(await kv.list())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('orders astral characters after the rest of the basic plane, as code points', async () => {
|
|
74
|
+
const kv = await fresh()
|
|
75
|
+
for (const key of ['\u{1F600}', '~', 'z', '']) await kv.set(key, 1)
|
|
76
|
+
expect(await kv.list()).toEqual(['z', '', '~', '\u{1F600}'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('treats % _ \\ and letter case in a prefix as plain characters', async () => {
|
|
80
|
+
const kv = await fresh()
|
|
81
|
+
for (const key of ['100%', '100x', 'a_b', 'axb', 'Abc', 'abc', 'back\\slash', 'backXslash']) await kv.set(key, 1)
|
|
82
|
+
expect(await kv.list('100%')).toEqual(['100%'])
|
|
83
|
+
expect(await kv.list('a_')).toEqual(['a_b'])
|
|
84
|
+
expect(await kv.list('ab')).toEqual(['abc'])
|
|
85
|
+
expect(await kv.list('Ab')).toEqual(['Abc'])
|
|
86
|
+
expect(await kv.list('back\\')).toEqual(['back\\slash'])
|
|
87
|
+
expect(await kv.list('\u{1F600}')).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('updates a missing key from undefined, and a present one from its value, resolving the new value', async () => {
|
|
91
|
+
const kv = await fresh()
|
|
92
|
+
expect(await kv.update<number>('n', current => (current ?? 0) + 1)).toBe(1)
|
|
93
|
+
expect(await kv.update<number>('n', current => (current ?? 0) + 10)).toBe(11)
|
|
94
|
+
expect(await kv.get('n')).toBe(11)
|
|
95
|
+
expect(await kv.update<string[]>('list', current => [...(current ?? []), 'a'])).toEqual(['a'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('counts fifty concurrent updates to exactly fifty', async () => {
|
|
99
|
+
const kv = await fresh()
|
|
100
|
+
await Promise.all(Array.from({ length: 50 }, () => kv.update<number>('counter', current => (current ?? 0) + 1)))
|
|
101
|
+
expect(await kv.get('counter')).toBe(50)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keeps updates of different keys independent, and a set between two updates in order', async () => {
|
|
105
|
+
const kv = await fresh()
|
|
106
|
+
const results = await Promise.all([
|
|
107
|
+
kv.update<number>('a', current => (current ?? 0) + 1),
|
|
108
|
+
kv.set('a', 100),
|
|
109
|
+
kv.update<number>('a', current => (current ?? 0) + 1),
|
|
110
|
+
kv.update<number>('b', current => (current ?? 0) + 5),
|
|
111
|
+
])
|
|
112
|
+
expect(results[0]).toBe(1)
|
|
113
|
+
expect(results[2]).toBe(101)
|
|
114
|
+
expect(await kv.get('a')).toBe(101)
|
|
115
|
+
expect(await kv.get('b')).toBe(5)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('leaves the value as it was when the update callback throws, and rejects with its error', async () => {
|
|
119
|
+
const kv = await fresh()
|
|
120
|
+
await kv.set('k', 'before')
|
|
121
|
+
const failure = new Error('stop')
|
|
122
|
+
await expect(kv.update('k', () => { throw failure })).rejects.toBe(failure)
|
|
123
|
+
expect(await kv.get('k')).toBe('before')
|
|
124
|
+
await expect(kv.update('absent', () => { throw failure })).rejects.toBe(failure)
|
|
125
|
+
expect(await kv.get('absent')).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('leaves the value as it was when the update returns something that is not JSON', async () => {
|
|
129
|
+
const kv = await fresh()
|
|
130
|
+
await kv.set('k', 'before')
|
|
131
|
+
await expect(kv.update('k', () => undefined as never)).rejects.toThrow(TypeError)
|
|
132
|
+
await expect(kv.update('k', () => Number.NaN as never)).rejects.toThrow(TypeError)
|
|
133
|
+
expect(await kv.get('k')).toBe('before')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects an update callback that returns a promise, with a message that says what to do, and keeps the value', async () => {
|
|
137
|
+
const kv = await fresh()
|
|
138
|
+
await kv.set('k', 1)
|
|
139
|
+
const asynchronous = (async () => 2) as unknown as (current: number | undefined) => number
|
|
140
|
+
await expect(kv.update<number>('k', asynchronous)).rejects.toThrow(/synchronous/u)
|
|
141
|
+
expect(await kv.get('k')).toBe(1)
|
|
142
|
+
await kv.update<number>('k', current => (current ?? 0) + 1)
|
|
143
|
+
expect(await kv.get('k')).toBe(2)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('refuses an empty or non-string key on every call, and writes nothing', async () => {
|
|
147
|
+
const kv = await fresh()
|
|
148
|
+
for (const key of ['', 1, null, undefined, {}] as unknown as string[]) {
|
|
149
|
+
await expect(kv.get(key)).rejects.toThrow(TypeError)
|
|
150
|
+
await expect(kv.set(key, 1)).rejects.toThrow(TypeError)
|
|
151
|
+
await expect(kv.update(key, () => 1)).rejects.toThrow(TypeError)
|
|
152
|
+
await expect(kv.delete(key)).rejects.toThrow(TypeError)
|
|
153
|
+
}
|
|
154
|
+
expect(await kv.list()).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('refuses a value that is not JSON with a TypeError, and writes nothing', async () => {
|
|
158
|
+
const kv = await fresh()
|
|
159
|
+
const circular: Record<string, unknown> = {}
|
|
160
|
+
circular.self = circular
|
|
161
|
+
for (const value of [undefined, () => 1, Symbol('s'), 1n, Number.NaN, Number.POSITIVE_INFINITY, circular, { deep: [Number.NEGATIVE_INFINITY] }]) {
|
|
162
|
+
await expect(kv.set('k', value), String(value)).rejects.toThrow(TypeError)
|
|
163
|
+
}
|
|
164
|
+
expect(await kv.list()).toEqual([])
|
|
165
|
+
await kv.set('k', 'kept')
|
|
166
|
+
await expect(kv.set('k', undefined)).rejects.toThrow(TypeError)
|
|
167
|
+
expect(await kv.get('k')).toBe('kept')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('hands out copies: changing what get returned, or the value after set, changes nothing stored', async () => {
|
|
171
|
+
const kv = await fresh()
|
|
172
|
+
const original = { list: [1, 2], nested: { n: 1 } }
|
|
173
|
+
await kv.set('k', original)
|
|
174
|
+
original.list.push(3)
|
|
175
|
+
const first = await kv.get<typeof original>('k')
|
|
176
|
+
expect(first).toEqual({ list: [1, 2], nested: { n: 1 } })
|
|
177
|
+
first!.nested.n = 99
|
|
178
|
+
expect(await kv.get('k')).toEqual({ list: [1, 2], nested: { n: 1 } })
|
|
179
|
+
const updated = await kv.update<typeof original>('k', current => {
|
|
180
|
+
current!.list.push(4)
|
|
181
|
+
return current!
|
|
182
|
+
})
|
|
183
|
+
updated.list.push(5)
|
|
184
|
+
expect(await kv.get('k')).toEqual({ list: [1, 2, 4], nested: { n: 1 } })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('stores a value of a megabyte', async () => {
|
|
188
|
+
const kv = await fresh()
|
|
189
|
+
const big = 'x'.repeat(1024 * 1024)
|
|
190
|
+
await kv.set('big', { big })
|
|
191
|
+
expect((await kv.get<{ big: string }>('big'))?.big.length).toBe(big.length)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('keeps what was written across close and reopen, and keeps a deleted key deleted', async () => {
|
|
195
|
+
const first = track(await open())
|
|
196
|
+
await first.kv.set('a', { n: 1 })
|
|
197
|
+
await first.kv.set('b', 'two')
|
|
198
|
+
await first.kv.update<number>('count', current => (current ?? 0) + 1)
|
|
199
|
+
await first.kv.set('gone', 1)
|
|
200
|
+
await first.kv.delete('gone')
|
|
201
|
+
const second = track(await first.reopen())
|
|
202
|
+
expect(await second.kv.list()).toEqual(['a', 'b', 'count'])
|
|
203
|
+
expect(await second.kv.get('a')).toEqual({ n: 1 })
|
|
204
|
+
expect(await second.kv.get('count')).toBe(1)
|
|
205
|
+
expect(await second.kv.get('gone')).toBeUndefined()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('rejects calls after close, and closing twice does nothing', async () => {
|
|
209
|
+
const subject = track(await open())
|
|
210
|
+
await subject.kv.set('k', 1)
|
|
211
|
+
await subject.close()
|
|
212
|
+
await subject.close()
|
|
213
|
+
await expect(subject.kv.get('k')).rejects.toThrow()
|
|
214
|
+
await expect(subject.kv.set('k', 2)).rejects.toThrow()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('waits for writes still in flight when it closes', async () => {
|
|
218
|
+
const subject = track(await open())
|
|
219
|
+
const writes = Array.from({ length: 20 }, (_, index) => subject.kv.set(`k${String(index).padStart(2, '0')}`, index))
|
|
220
|
+
await subject.close()
|
|
221
|
+
await Promise.all(writes)
|
|
222
|
+
const again = track(await subject.reopen())
|
|
223
|
+
expect(await again.kv.list()).toHaveLength(20)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
}
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
11
|
"mount": "mount it after acryl-db-sqlite: root.plugin(kvDb, {}); it waits for db and provides the same kv as acryl-kv-json",
|
|
12
|
-
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
|
|
12
|
+
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
13
|
+
"imports": {
|
|
14
|
+
"#acryl-kv-db": "acryl-kv-db/src/index.ts"
|
|
15
|
+
}
|
|
13
16
|
}
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
11
|
"mount": "mount it after instance: root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
|
|
12
|
-
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
|
|
12
|
+
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
13
|
+
"imports": {
|
|
14
|
+
"#acryl-kv-json": "acryl-kv-json/src/index.ts"
|
|
15
|
+
}
|
|
13
16
|
}
|
|
@@ -89,6 +89,11 @@ const drainIncoming = (incoming: IncomingMessage | Http2ServerRequest): void =>
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// A body reader the app stopped using (Readable.toWeb behind bodyLimit, for one) still listens for
|
|
93
|
+
// data and pauses the request again as soon as its queue is full, so the drain would stall after
|
|
94
|
+
// one chunk and the timer would close a socket with unread data, which the kernel answers with RST.
|
|
95
|
+
// Nobody reads the body any more, so the drain is the only data listener.
|
|
96
|
+
incoming.removeAllListeners('data')
|
|
92
97
|
incoming.on('data', onData)
|
|
93
98
|
incoming.on('end', cleanup)
|
|
94
99
|
incoming.on('error', cleanup)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-node-server",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 31,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:4142980d289b36c661e572ecbb5f19f06f0c95fc277ce4dca9b543d366c291e8",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/client-modules.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); clientModules and build-client are exercised here by the templates' tests and verify:scaffolder",
|
|
16
16
|
"tests/register.spec.ts": "links typescript from the plugin's own node_modules (ACRYL's workspace layout); the hook is exercised here by the templates' own .tsx tests and verify:scaffolder"
|
|
@@ -198,6 +198,9 @@ export class Router extends Service {
|
|
|
198
198
|
}
|
|
199
199
|
;(handler as unknown as Record<string, unknown>)[COMPOSED_HANDLER] = inner
|
|
200
200
|
tagged.on(route.method, route.path, handler)
|
|
201
|
+
// `on` registers under the base path of `tagged` (`/`), which would hide the prefix of a nested app from `basePath(c)`; keep the route's own.
|
|
202
|
+
const registered = tagged.routes.at(-1)
|
|
203
|
+
if (registered !== undefined) registered.basePath = route.basePath
|
|
201
204
|
}
|
|
202
205
|
return mount
|
|
203
206
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "plugins/acryl-router",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
6
6
|
},
|
|
7
7
|
"vendored": [
|
|
8
8
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"rewrittenSpecifiers": 6,
|
|
13
|
-
"digest": "sha256:
|
|
13
|
+
"digest": "sha256:f9679acf23d0a647a3f8bf9bb3cb9107eb03d25192ab401506e3a4ee570e305e",
|
|
14
14
|
"testsNotRun": {
|
|
15
15
|
"tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
|
|
16
16
|
},
|