-
Notifications
You must be signed in to change notification settings - Fork 8
[APPS] Add backend function connection support #331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 9 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
513da3c
[APPS] Add connections.ts support: emit backend/manifest.json and for…
sdkennedy2 808fd00
Move buildStart into vite sub-plugin to drop unsafe cast on this.load
sdkennedy2 ef8ff68
Trim verbose comments in apps plugin entry and vite hooks
sdkennedy2 5bd5aef
Accept either 'connections' or 'CONNECTIONS' as the export name in co…
sdkennedy2 1c5691e
Fix dev startup, surface framed errors, add line:col to connections.t…
sdkennedy2 cbd16e2
Move manifest.json to zip root and nest under backend.functions per u…
sdkennedy2 e7ee68b
Refresh connections registry via server.watcher; fail-closed on extra…
sdkennedy2 2b56c08
Inline describeNode helper and drop unused Expression import
sdkennedy2 3173652
Defer reading buildRoot in connections registry until configResolved
sdkennedy2 b64302d
Tighten connections export name to CONNECTIONS only; add E2E manifest…
sdkennedy2 c605f18
Use injected Vite parser for connection IDs
sdkennedy2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
437 changes: 437 additions & 0 deletions
437
packages/plugins/apps/src/backend/extract-connections.test.ts
Large diffs are not rendered by default.
Oops, something went wrong.
179 changes: 179 additions & 0 deletions
179
packages/plugins/apps/src/backend/extract-connections.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| // Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. | ||
| // This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
| // Copyright 2019-Present Datadog, Inc. | ||
|
|
||
| import type { Node, ObjectExpression, Program, Property } from 'estree'; | ||
| import { promises as fsp } from 'fs'; | ||
| import path from 'path'; | ||
|
|
||
| const CONNECTIONS_FILE_BASENAME = 'connections'; | ||
| const CONNECTIONS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'] as const; | ||
| const CONNECTIONS_EXPORT_NAMES = ['connections', 'CONNECTIONS'] as const; | ||
| const EXPECTED_EXPORT_DESCRIPTION = '"export const CONNECTIONS" (or "connections")'; | ||
|
|
||
| /** | ||
| * Locate the project's connections file. Looks for `connections.{ts,tsx,js,jsx}` | ||
| * at `buildRoot` and returns the absolute path of the first match in priority | ||
| * order, or `undefined` when none exists. | ||
| */ | ||
| export async function findConnectionsFile(buildRoot: string): Promise<string | undefined> { | ||
| for (const ext of CONNECTIONS_EXTENSIONS) { | ||
| const candidate = path.join(buildRoot, `${CONNECTIONS_FILE_BASENAME}${ext}`); | ||
| try { | ||
| await fsp.access(candidate); | ||
| return candidate; | ||
| } catch { | ||
| // not found at this extension — try the next. | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| type WithOffset = Node & { start?: number }; | ||
|
|
||
| /** | ||
| * Extract connection IDs from a parsed connections-file AST. | ||
| * | ||
| * The file must contain exactly one top-level export of the form: | ||
| * | ||
| * export const CONNECTIONS = { | ||
| * NAME_A: 'uuid-a', | ||
| * NAME_B: 'uuid-b', | ||
| * } as const; | ||
| * | ||
| * `connections` (lowercase) is also accepted as the variable name. | ||
| * | ||
| * Values must be plain string literals or interpolation-free template literals. | ||
| * Anything else (identifiers, env vars, concatenation, function calls, computed | ||
| * keys, spread elements, …) throws with a framed source location so the caller | ||
| * can surface a build-time error. `code` is the original source text used to | ||
| * resolve `node.start` offsets to line:col coordinates. | ||
| * | ||
| * Returns the union of values, deduplicated and sorted lexicographically for | ||
| * deterministic manifests. | ||
| */ | ||
| export function extractConnectionIds(ast: Program, filePath: string, code: string): string[] { | ||
| if (ast.type !== 'Program') { | ||
| throw new Error( | ||
| `Expected a Program node from this.parse() for ${filePath}, got ${(ast as Node).type}`, | ||
| ); | ||
| } | ||
|
|
||
| const fail = (node: WithOffset | null | undefined, reason: string): Error => { | ||
| const where = | ||
| node?.start != null ? `${filePath}:${formatLineCol(code, node.start)}` : filePath; | ||
| return new Error(`[connections] ${reason} (at ${where})`); | ||
| }; | ||
|
|
||
| let connectionsObject: ObjectExpression | undefined; | ||
|
|
||
| for (const node of ast.body) { | ||
| if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { | ||
| continue; | ||
| } | ||
| const decl = node.declaration; | ||
| if (decl.type !== 'VariableDeclaration') { | ||
| continue; | ||
| } | ||
| for (const d of decl.declarations) { | ||
| if (d.id.type !== 'Identifier' || !isConnectionsExportName(d.id.name)) { | ||
| continue; | ||
| } | ||
| if (connectionsObject) { | ||
| throw fail( | ||
| d, | ||
| `multiple top-level ${EXPECTED_EXPORT_DESCRIPTION} declarations are not allowed`, | ||
| ); | ||
| } | ||
| if (!d.init || d.init.type !== 'ObjectExpression') { | ||
| throw fail( | ||
| d.init ?? d, | ||
| `${EXPECTED_EXPORT_DESCRIPTION} must be initialized with an object literal`, | ||
| ); | ||
| } | ||
| connectionsObject = d.init; | ||
| } | ||
| } | ||
|
|
||
| if (!connectionsObject) { | ||
| throw fail(null, `connections file must define ${EXPECTED_EXPORT_DESCRIPTION} = { ... }`); | ||
| } | ||
|
|
||
| const ids = new Set<string>(); | ||
| for (const property of connectionsObject.properties) { | ||
| if (property.type === 'SpreadElement') { | ||
| throw fail( | ||
| property, | ||
| `spread elements are not supported inside ${EXPECTED_EXPORT_DESCRIPTION}`, | ||
| ); | ||
| } | ||
| if (property.computed) { | ||
| throw fail( | ||
| property, | ||
| `computed keys are not supported inside ${EXPECTED_EXPORT_DESCRIPTION}`, | ||
| ); | ||
| } | ||
| const keyName = readKeyName(property); | ||
| const value = extractStaticString(property.value, keyName, fail); | ||
| ids.add(value); | ||
| } | ||
|
|
||
| return [...ids].sort(); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a property value node to its static string. Accepts string literals | ||
| * and interpolation-free template literals; throws on anything else. | ||
| */ | ||
| function extractStaticString( | ||
| value: Property['value'], | ||
| keyName: string, | ||
| fail: (node: WithOffset | null | undefined, reason: string) => Error, | ||
| ): string { | ||
| if (value.type === 'Literal' && typeof value.value === 'string') { | ||
| return value.value; | ||
| } | ||
| if (value.type === 'TemplateLiteral') { | ||
| if (value.expressions.length > 0) { | ||
| throw fail( | ||
| value, | ||
| `value for "${keyName}" must be a static string — template literals with interpolations are not allowed`, | ||
| ); | ||
| } | ||
| const quasi = value.quasis[0]; | ||
| return quasi.value.cooked ?? quasi.value.raw; | ||
| } | ||
| throw fail(value, `value for "${keyName}" must be a string literal; got ${value.type}`); | ||
| } | ||
|
|
||
| /** | ||
| * Read a property's key name as a string. Computed keys are rejected upstream, | ||
| * so this only handles `Identifier` (e.g. `OPEN_AI: '...'`) and string | ||
| * `Literal` (`'open-ai': '...'`) forms. | ||
| */ | ||
| function readKeyName(property: Property): string { | ||
| if (property.key.type === 'Identifier') { | ||
| return property.key.name; | ||
| } | ||
| if (property.key.type === 'Literal') { | ||
| return String(property.key.value); | ||
| } | ||
| return '<unknown>'; | ||
| } | ||
|
|
||
| function isConnectionsExportName(name: string): name is (typeof CONNECTIONS_EXPORT_NAMES)[number] { | ||
| return (CONNECTIONS_EXPORT_NAMES as readonly string[]).includes(name); | ||
| } | ||
|
|
||
| /** | ||
| * Convert a 0-based byte offset into a `line:column` string (1-based, like | ||
| * editor jump-to-line targets). | ||
| */ | ||
| function formatLineCol(code: string, offset: number): string { | ||
| const before = code.slice(0, offset); | ||
| const newlineCount = (before.match(/\n/g) ?? []).length; | ||
| const lastNewline = before.lastIndexOf('\n'); | ||
| const line = newlineCount + 1; | ||
| const column = offset - (lastNewline + 1) + 1; | ||
| return `${line}:${column}`; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will swallow any errors, not just the "not found" errors.
Maybe a specific gate of
ENOENTerrors only would be necessary.Unless we're ok with this.