@agent-native/core 0.116.0 → 0.117.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 (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +4 -1
  4. package/corpus/core/src/client/analytics.ts +7 -0
  5. package/corpus/core/src/client/use-db-sync.ts +354 -15
  6. package/corpus/core/src/collab/client.ts +26 -2
  7. package/corpus/core/src/db/ddl-guard.ts +49 -10
  8. package/corpus/core/src/deploy/build.ts +25 -2
  9. package/corpus/core/src/realtime-protocol.ts +92 -0
  10. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  11. package/corpus/core/src/server/poll-events.ts +29 -8
  12. package/corpus/core/src/server/poll.ts +1123 -865
  13. package/corpus/core/src/server/realtime-token.ts +106 -0
  14. package/corpus/core/src/server/sentry-config.ts +40 -0
  15. package/corpus/core/src/server/short-lived-token.ts +156 -0
  16. package/corpus/core/src/server/sse.ts +15 -0
  17. package/corpus/core/src/server/ssr-handler.ts +9 -3
  18. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  22. package/corpus/templates/content/app/i18n-data.ts +90 -0
  23. package/dist/client/analytics.d.ts +10 -0
  24. package/dist/client/analytics.d.ts.map +1 -1
  25. package/dist/client/analytics.js.map +1 -1
  26. package/dist/client/use-db-sync.d.ts +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +315 -15
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/client.d.ts.map +1 -1
  31. package/dist/collab/client.js +26 -2
  32. package/dist/collab/client.js.map +1 -1
  33. package/dist/db/ddl-guard.d.ts +7 -2
  34. package/dist/db/ddl-guard.d.ts.map +1 -1
  35. package/dist/db/ddl-guard.js +21 -9
  36. package/dist/db/ddl-guard.js.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +25 -2
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/notifications/routes.d.ts +3 -3
  41. package/dist/observability/routes.d.ts +5 -5
  42. package/dist/progress/routes.d.ts +1 -1
  43. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  44. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  45. package/dist/realtime-protocol.d.ts +40 -0
  46. package/dist/realtime-protocol.d.ts.map +1 -0
  47. package/dist/realtime-protocol.js +69 -0
  48. package/dist/realtime-protocol.js.map +1 -0
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +3 -3
  51. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  52. package/dist/server/core-routes-plugin.js +3 -0
  53. package/dist/server/core-routes-plugin.js.map +1 -1
  54. package/dist/server/poll-events.d.ts +7 -1
  55. package/dist/server/poll-events.d.ts.map +1 -1
  56. package/dist/server/poll-events.js +24 -7
  57. package/dist/server/poll-events.js.map +1 -1
  58. package/dist/server/poll.d.ts +223 -42
  59. package/dist/server/poll.d.ts.map +1 -1
  60. package/dist/server/poll.js +849 -737
  61. package/dist/server/poll.js.map +1 -1
  62. package/dist/server/realtime-token.d.ts +34 -0
  63. package/dist/server/realtime-token.d.ts.map +1 -0
  64. package/dist/server/realtime-token.js +85 -0
  65. package/dist/server/realtime-token.js.map +1 -0
  66. package/dist/server/sentry-config.d.ts +12 -0
  67. package/dist/server/sentry-config.d.ts.map +1 -1
  68. package/dist/server/sentry-config.js +34 -0
  69. package/dist/server/sentry-config.js.map +1 -1
  70. package/dist/server/short-lived-token.d.ts +46 -0
  71. package/dist/server/short-lived-token.d.ts.map +1 -1
  72. package/dist/server/short-lived-token.js +104 -0
  73. package/dist/server/short-lived-token.js.map +1 -1
  74. package/dist/server/sse.d.ts +1 -1
  75. package/dist/server/sse.d.ts.map +1 -1
  76. package/dist/server/sse.js +4 -0
  77. package/dist/server/sse.js.map +1 -1
  78. package/dist/server/ssr-handler.d.ts.map +1 -1
  79. package/dist/server/ssr-handler.js +5 -3
  80. package/dist/server/ssr-handler.js.map +1 -1
  81. package/dist/server/transcribe-voice.d.ts +1 -1
  82. package/package.json +4 -1
  83. package/src/client/analytics.ts +7 -0
  84. package/src/client/use-db-sync.ts +354 -15
  85. package/src/collab/client.ts +26 -2
  86. package/src/db/ddl-guard.ts +49 -10
  87. package/src/deploy/build.ts +25 -2
  88. package/src/realtime-protocol.ts +92 -0
  89. package/src/server/core-routes-plugin.ts +7 -0
  90. package/src/server/poll-events.ts +29 -8
  91. package/src/server/poll.ts +1123 -865
  92. package/src/server/realtime-token.ts +106 -0
  93. package/src/server/sentry-config.ts +40 -0
  94. package/src/server/short-lived-token.ts +156 -0
  95. package/src/server/sse.ts +15 -0
  96. package/src/server/ssr-handler.ts +9 -3
@@ -46,8 +46,14 @@ const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
46
46
  export async function pgTableExists(
47
47
  table: string,
48
48
  injectedClient?: DbExec,
49
+ dialectIsPostgres?: boolean,
49
50
  ): Promise<boolean> {
50
- if (!isPostgres() || !PLAIN_IDENTIFIER.test(table)) return false;
51
+ // The dialect override travels with an injected client: a multi-app process
52
+ // (the hosted Realtime Gateway) probes a per-app Postgres DB even when its
53
+ // own process-global DB is absent or SQLite.
54
+ if (!(dialectIsPostgres ?? isPostgres()) || !PLAIN_IDENTIFIER.test(table)) {
55
+ return false;
56
+ }
51
57
  const client = injectedClient ?? getDbExec();
52
58
  try {
53
59
  const { rows } = await client.execute({
@@ -106,8 +112,14 @@ export async function pgColumnExists(
106
112
  export async function pgIndexExists(
107
113
  indexName: string,
108
114
  injectedClient?: DbExec,
115
+ dialectIsPostgres?: boolean,
109
116
  ): Promise<boolean> {
110
- if (!isPostgres() || !PLAIN_IDENTIFIER.test(indexName)) return false;
117
+ if (
118
+ !(dialectIsPostgres ?? isPostgres()) ||
119
+ !PLAIN_IDENTIFIER.test(indexName)
120
+ ) {
121
+ return false;
122
+ }
111
123
  const client = injectedClient ?? getDbExec();
112
124
  try {
113
125
  const { rows } = await client.execute({
@@ -158,10 +170,17 @@ export async function ensureSchemaObject(options: {
158
170
  lockTimeout?: string;
159
171
  /** Injectable client for tests. */
160
172
  injectedClient?: DbExec;
173
+ /** Dialect override for injected per-app clients; defaults to the global. */
174
+ dialectIsPostgres?: boolean;
161
175
  }): Promise<boolean> {
162
- const { probe, ddl, label, lockTimeout, injectedClient } = options;
176
+ const { probe, ddl, label, lockTimeout, injectedClient, dialectIsPostgres } =
177
+ options;
163
178
  if (await probe()) return false;
164
- const ran = await runGuardedDdl(ddl, { lockTimeout, injectedClient });
179
+ const ran = await runGuardedDdl(ddl, {
180
+ lockTimeout,
181
+ injectedClient,
182
+ dialectIsPostgres,
183
+ });
165
184
  if (ran) return true;
166
185
  // The DDL was swallowed by a lock-timeout. The object is virtually always
167
186
  // already correct by the time a contended boot retries (a concurrent
@@ -185,14 +204,20 @@ export async function ensureSchemaObject(options: {
185
204
  export async function ensureTableExists(
186
205
  table: string,
187
206
  createSql: string,
188
- options: { lockTimeout?: string; injectedClient?: DbExec } = {},
207
+ options: {
208
+ lockTimeout?: string;
209
+ injectedClient?: DbExec;
210
+ dialectIsPostgres?: boolean;
211
+ } = {},
189
212
  ): Promise<boolean> {
190
213
  return ensureSchemaObject({
191
- probe: () => pgTableExists(table, options.injectedClient),
214
+ probe: () =>
215
+ pgTableExists(table, options.injectedClient, options.dialectIsPostgres),
192
216
  ddl: createSql,
193
217
  label: `table ${table}`,
194
218
  lockTimeout: options.lockTimeout,
195
219
  injectedClient: options.injectedClient,
220
+ dialectIsPostgres: options.dialectIsPostgres,
196
221
  });
197
222
  }
198
223
 
@@ -222,14 +247,24 @@ export async function ensureColumnExists(
222
247
  export async function ensureIndexExists(
223
248
  indexName: string,
224
249
  createIndexSql: string,
225
- options: { lockTimeout?: string; injectedClient?: DbExec } = {},
250
+ options: {
251
+ lockTimeout?: string;
252
+ injectedClient?: DbExec;
253
+ dialectIsPostgres?: boolean;
254
+ } = {},
226
255
  ): Promise<boolean> {
227
256
  return ensureSchemaObject({
228
- probe: () => pgIndexExists(indexName, options.injectedClient),
257
+ probe: () =>
258
+ pgIndexExists(
259
+ indexName,
260
+ options.injectedClient,
261
+ options.dialectIsPostgres,
262
+ ),
229
263
  ddl: createIndexSql,
230
264
  label: `index ${indexName}`,
231
265
  lockTimeout: options.lockTimeout,
232
266
  injectedClient: options.injectedClient,
267
+ dialectIsPostgres: options.dialectIsPostgres,
233
268
  });
234
269
  }
235
270
 
@@ -263,10 +298,14 @@ export function isLockTimeoutError(err: unknown): boolean {
263
298
  */
264
299
  export async function runGuardedDdl(
265
300
  ddl: string,
266
- options: { lockTimeout?: string; injectedClient?: DbExec } = {},
301
+ options: {
302
+ lockTimeout?: string;
303
+ injectedClient?: DbExec;
304
+ dialectIsPostgres?: boolean;
305
+ } = {},
267
306
  ): Promise<boolean> {
268
307
  const client = options.injectedClient ?? getDbExec();
269
- if (!isPostgres()) {
308
+ if (!(options.dialectIsPostgres ?? isPostgres())) {
270
309
  await client.execute(ddl);
271
310
  return true;
272
311
  }
@@ -951,6 +951,26 @@ function getSentryClientConfigScript() {
951
951
  );
952
952
  }
953
953
 
954
+ function getRealtimeClientConfigScript() {
955
+ // MUST stay byte-for-byte consistent with resolveRealtimeClientConfig in
956
+ // server/sentry-config.ts (worker bundles a string copy; it can't import it).
957
+ // Fail closed: require BOTH hosted transport AND an explicit gateway URL — no
958
+ // production default, since this ships into the CDN-cached shell.
959
+ const env = globalThis.process?.env || {};
960
+ if (firstNonEmpty(env.AGENT_NATIVE_REALTIME_TRANSPORT) !== "hosted") {
961
+ return null;
962
+ }
963
+ const gatewayBaseUrl = firstNonEmpty(env.AGENT_NATIVE_REALTIME_GATEWAY_URL);
964
+ if (!gatewayBaseUrl) return null;
965
+ const config = { realtime: { transport: "hosted", gatewayBaseUrl } };
966
+ return (
967
+ '<script data-agent-native-realtime-config>' +
968
+ 'window.__AGENT_NATIVE_CONFIG__=Object.assign({},window.__AGENT_NATIVE_CONFIG__,' +
969
+ JSON.stringify(config) +
970
+ ");</script>"
971
+ );
972
+ }
973
+
954
974
  function injectHeadScript(html, script) {
955
975
  if (!script) return html;
956
976
  const headCloseIdx = html.indexOf("</head>");
@@ -1102,7 +1122,10 @@ function applyImmutableAssetCacheHeaders(response, request) {
1102
1122
  }
1103
1123
 
1104
1124
  async function rewriteMountedResponse(response, basePath, pathname, request) {
1105
- const sentryClientConfigScript = getSentryClientConfigScript();
1125
+ const clientConfigScript =
1126
+ [getSentryClientConfigScript(), getRealtimeClientConfigScript()]
1127
+ .filter(Boolean)
1128
+ .join("") || null;
1106
1129
  const headers = new Headers(response.headers);
1107
1130
  applyDefaultSsrCacheHeader(headers, response.status, pathname);
1108
1131
  applyDefaultSpeculationRulesHeader(headers, response.status, basePath);
@@ -1129,7 +1152,7 @@ async function rewriteMountedResponse(response, basePath, pathname, request) {
1129
1152
  prefixMountedHtml(html, basePath),
1130
1153
  defaultSocialImageUrl(request, basePath),
1131
1154
  ),
1132
- sentryClientConfigScript,
1155
+ clientConfigScript,
1133
1156
  ),
1134
1157
  {
1135
1158
  status: response.status,
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Realtime SSE wire protocol — shared by the server SSE handlers (framework
3
+ * in-process path + the hosted Realtime Gateway) and the client transport.
4
+ *
5
+ * Pure constants, types, and parsers only — NO h3/node/browser deps — so both
6
+ * the Node server and the browser client import it.
7
+ *
8
+ * Frame taxonomy:
9
+ * - CHANGE / batch frames ride the default (unnamed) SSE `message` event and
10
+ * are parsed by the client's existing `normalizeEventPayload`. Unchanged.
11
+ * - CONTROL frames (handshake, token rotation) ride NAMED SSE events, so
12
+ * `EventSource.addEventListener(name)` routes them separately and an older
13
+ * client that only reads `onmessage` silently ignores them (backward safe).
14
+ */
15
+
16
+ export const REALTIME_PROTOCOL_VERSION = 1;
17
+
18
+ /** Named SSE event carrying the one-time handshake ({@link RealtimeHandshake}). */
19
+ export const REALTIME_SSE_HANDSHAKE_EVENT = "handshake";
20
+ /** Named SSE event carrying a rotated subscribe token ({@link RealtimeTokenFrame}). */
21
+ export const REALTIME_SSE_TOKEN_EVENT = "token";
22
+
23
+ /**
24
+ * Capability advertised by the hosted gateway: awareness/presence is NOT
25
+ * forwarded (it lives only on the in-process emitter, never in `sync_events`),
26
+ * so the collab client must keep its own presence cadence. The framework's
27
+ * in-process SSE path forwards awareness and therefore sends no handshake — the
28
+ * client treats an absent handshake as "all capabilities present."
29
+ */
30
+ export const REALTIME_CAP_NO_AWARENESS = "no-awareness";
31
+
32
+ export interface RealtimeHandshake {
33
+ protocol: number;
34
+ capabilities: string[];
35
+ }
36
+
37
+ export interface RealtimeTokenFrame {
38
+ token: string;
39
+ /** ISO expiry, informational; the client re-mints/rotates before this. */
40
+ expiresAt?: string;
41
+ }
42
+
43
+ export function buildHandshakeFrame(
44
+ capabilities: readonly string[] = [],
45
+ ): RealtimeHandshake {
46
+ return {
47
+ protocol: REALTIME_PROTOCOL_VERSION,
48
+ capabilities: [...capabilities],
49
+ };
50
+ }
51
+
52
+ export function parseHandshakeFrame(data: string): RealtimeHandshake | null {
53
+ try {
54
+ const parsed = JSON.parse(data);
55
+ if (
56
+ parsed &&
57
+ typeof parsed === "object" &&
58
+ typeof parsed.protocol === "number" &&
59
+ Array.isArray(parsed.capabilities)
60
+ ) {
61
+ return {
62
+ protocol: parsed.protocol,
63
+ capabilities: parsed.capabilities.filter(
64
+ (c: unknown): c is string => typeof c === "string",
65
+ ),
66
+ };
67
+ }
68
+ } catch {
69
+ // fall through
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function parseTokenFrame(data: string): RealtimeTokenFrame | null {
75
+ try {
76
+ const parsed = JSON.parse(data);
77
+ if (
78
+ parsed &&
79
+ typeof parsed === "object" &&
80
+ typeof parsed.token === "string"
81
+ ) {
82
+ return {
83
+ token: parsed.token,
84
+ expiresAt:
85
+ typeof parsed.expiresAt === "string" ? parsed.expiresAt : undefined,
86
+ };
87
+ }
88
+ } catch {
89
+ // fall through
90
+ }
91
+ return null;
92
+ }
@@ -170,6 +170,7 @@ import { handleIdentitySso } from "./identity-sso.js";
170
170
  import { createOpenRouteHandler } from "./open-route.js";
171
171
  import { createPollEventsHandler } from "./poll-events.js";
172
172
  import { createPollHandler } from "./poll.js";
173
+ import { createRealtimeTokenHandler } from "./realtime-token.js";
173
174
  import { runWithRequestContext } from "./request-context.js";
174
175
  import {
175
176
  findUnsupportedScopedKeyNames,
@@ -1414,6 +1415,12 @@ export function createCoreRoutesPlugin(
1414
1415
  // Polling
1415
1416
  getH3App(nitroApp).use(`${P}/poll`, createPollHandler());
1416
1417
 
1418
+ // Realtime subscribe-token mint (hosted gateway path)
1419
+ getH3App(nitroApp).use(
1420
+ `${P}/realtime-token`,
1421
+ createRealtimeTokenHandler(),
1422
+ );
1423
+
1417
1424
  // SSE
1418
1425
  if (!options.disableSSE) {
1419
1426
  for (const route of resolveFrameworkSseRoutes(options.sseRoute)) {
@@ -7,10 +7,11 @@ import {
7
7
  } from "../collab/awareness.js";
8
8
  import { getSession } from "./auth.js";
9
9
  import {
10
+ type AppSyncState,
10
11
  canSeeChangeForUser,
11
- getPollEmitter,
12
- POLL_CHANGE_EVENT,
13
12
  type ChangeEvent,
13
+ getDefaultAppSyncState,
14
+ POLL_CHANGE_EVENT,
14
15
  } from "./poll.js";
15
16
 
16
17
  export function canSeeAwarenessChangeForUser(
@@ -37,8 +38,15 @@ export function canSeeAwarenessChangeForUser(
37
38
  * connected peers receive cursor moves push-style instead of waiting for
38
39
  * the next poll cycle. Polling fallback keeps working — cursors degrade
39
40
  * gracefully to poll cadence without SSE.
41
+ *
42
+ * `state` defaults to the process-wide instance so self-hosted apps are
43
+ * unchanged; the hosted gateway passes a per-app instance to fan out that
44
+ * app's events (awareness stays on the process-global emitter and is a v0
45
+ * gateway Non-Goal).
40
46
  */
41
- export function createPollEventsHandler() {
47
+ export function createPollEventsHandler(
48
+ state: AppSyncState = getDefaultAppSyncState(),
49
+ ) {
42
50
  return defineEventHandler(async (event) => {
43
51
  const session = await getSession(event).catch(() => null);
44
52
  if (!session?.email) {
@@ -60,7 +68,9 @@ export function createPollEventsHandler() {
60
68
 
61
69
  const push = (change: ChangeEvent) => {
62
70
  if (closed) return;
63
- if (!canSeeChangeForUser(change, session.email, session.orgId)) return;
71
+ if (!state.canSeeChangeForUser(change, session.email, session.orgId)) {
72
+ return;
73
+ }
64
74
  safePush(JSON.stringify(change));
65
75
  };
66
76
 
@@ -74,13 +84,24 @@ export function createPollEventsHandler() {
74
84
  safePush(JSON.stringify(change));
75
85
  };
76
86
 
77
- getPollEmitter().on(POLL_CHANGE_EVENT, push);
78
- getAwarenessEmitter().on(AWARENESS_CHANGE_EVENT, pushAwareness);
87
+ state.getPollEmitter().on(POLL_CHANGE_EVENT, push);
88
+ // Awareness lives ONLY on the process-global emitter and is filtered via the
89
+ // module-level (default-state) access path — both untenable in a multi-app
90
+ // gateway process, where they'd leak another app's presence/document id and
91
+ // resolve access against the wrong tenant. Fail closed: only the default
92
+ // (in-process) instance forwards awareness. The gateway advertises
93
+ // `no-awareness` so collab keeps its own presence cadence.
94
+ const forwardAwareness = state === getDefaultAppSyncState();
95
+ if (forwardAwareness) {
96
+ getAwarenessEmitter().on(AWARENESS_CHANGE_EVENT, pushAwareness);
97
+ }
79
98
 
80
99
  stream.onClosed(() => {
81
100
  closed = true;
82
- getPollEmitter().off(POLL_CHANGE_EVENT, push);
83
- getAwarenessEmitter().off(AWARENESS_CHANGE_EVENT, pushAwareness);
101
+ state.getPollEmitter().off(POLL_CHANGE_EVENT, push);
102
+ if (forwardAwareness) {
103
+ getAwarenessEmitter().off(AWARENESS_CHANGE_EVENT, pushAwareness);
104
+ }
84
105
  });
85
106
 
86
107
  return stream.send();