@appliance.sh/api-server 1.20.0 → 1.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appliance.sh/api-server",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -19,8 +19,8 @@
19
19
  "test:e2e": "vitest run --config vitest.e2e.config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@appliance.sh/infra": "1.20.0",
23
- "@appliance.sh/sdk": "1.20.0",
22
+ "@appliance.sh/infra": "1.21.0",
23
+ "@appliance.sh/sdk": "1.21.0",
24
24
  "@aws-sdk/client-ecr": "^3.1005.0",
25
25
  "@aws-sdk/client-s3": "^3.750.0",
26
26
  "express": "^5.2.1"
@@ -1,7 +1,7 @@
1
- import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
1
+ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
2
2
  import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
3
3
  import { applianceBaseConfig, applianceInput } from '@appliance.sh/sdk';
4
- import type { ApplianceContainer } from '@appliance.sh/sdk';
4
+ import type { ApplianceContainer, ApplianceFrameworkApp } from '@appliance.sh/sdk';
5
5
  import { execSync } from 'child_process';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
@@ -10,6 +10,11 @@ import * as os from 'node:os';
10
10
  export interface ResolvedBuild {
11
11
  imageUri?: string;
12
12
  codeS3Key?: string;
13
+ runtime?: string;
14
+ handler?: string;
15
+ layers?: string[];
16
+ architectures?: string[];
17
+ environment?: Record<string, string>;
13
18
  }
14
19
 
15
20
  function getBaseConfig() {
@@ -18,6 +23,23 @@ function getBaseConfig() {
18
23
  return applianceBaseConfig.parse(JSON.parse(raw));
19
24
  }
20
25
 
26
+ const LAMBDA_ADAPTER_LAYER: Record<string, string> = {
27
+ 'linux/amd64': 'arn:aws:lambda:${region}:753240598075:layer:LambdaAdapterLayerX86:26',
28
+ 'linux/arm64': 'arn:aws:lambda:${region}:753240598075:layer:LambdaAdapterLayerArm64:26',
29
+ };
30
+
31
+ const FRAMEWORK_RUNTIMES: Record<string, string> = {
32
+ node: 'nodejs22.x',
33
+ python: 'python3.13',
34
+ auto: 'nodejs22.x',
35
+ other: 'nodejs22.x',
36
+ };
37
+
38
+ const FRAMEWORK_ARCHITECTURES: Record<string, string> = {
39
+ 'linux/amd64': 'x86_64',
40
+ 'linux/arm64': 'arm64',
41
+ };
42
+
21
43
  export class BuildService {
22
44
  async resolve(buildId: string, tag: string): Promise<ResolvedBuild> {
23
45
  const config = getBaseConfig();
@@ -55,8 +77,9 @@ export class BuildService {
55
77
 
56
78
  if (manifest.type === 'container') {
57
79
  return this.resolveContainer(tmpDir, manifest, tag, config);
80
+ } else if (manifest.type === 'framework') {
81
+ return this.resolveFramework(tmpDir, manifest, tag, config);
58
82
  } else {
59
- // For framework/other, the zip is already in S3 — just reference it
60
83
  return { codeS3Key: s3Key };
61
84
  }
62
85
  } finally {
@@ -64,6 +87,84 @@ export class BuildService {
64
87
  }
65
88
  }
66
89
 
90
+ private async resolveFramework(
91
+ tmpDir: string,
92
+ manifest: ApplianceFrameworkApp,
93
+ tag: string,
94
+ config: ReturnType<typeof getBaseConfig>
95
+ ): Promise<ResolvedBuild> {
96
+ if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
97
+
98
+ const port = manifest.port ?? 8080;
99
+ const framework = manifest.framework ?? 'auto';
100
+ const detectedFramework = framework === 'auto' ? this.detectFramework(tmpDir) : framework;
101
+ const runtime = FRAMEWORK_RUNTIMES[detectedFramework] ?? FRAMEWORK_RUNTIMES['node'];
102
+
103
+ // Generate a run.sh that starts the web server
104
+ const startCommand = manifest.scripts?.start ?? this.defaultStartCommand(detectedFramework, tmpDir);
105
+ const runSh = ['#!/bin/bash', `export PORT=${port}`, `exec ${startCommand}`].join('\n');
106
+ fs.writeFileSync(path.join(tmpDir, 'run.sh'), runSh, { mode: 0o755 });
107
+
108
+ // Repackage as a new zip (excluding the original zip and manifest-only artifacts)
109
+ const repackagedKey = `builds/${tag}.zip`;
110
+ const repackagedZip = path.join(tmpDir, 'repackaged.zip');
111
+ execSync(`cd "${tmpDir}" && zip -r "${repackagedZip}" . -x "appliance.zip" -x "repackaged.zip"`, { stdio: 'pipe' });
112
+
113
+ // Upload the repackaged zip
114
+ const s3 = new S3Client({ region: config.aws.region });
115
+ await s3.send(
116
+ new PutObjectCommand({
117
+ Bucket: config.aws.dataBucketName,
118
+ Key: repackagedKey,
119
+ Body: fs.readFileSync(repackagedZip),
120
+ ContentType: 'application/zip',
121
+ })
122
+ );
123
+
124
+ // Resolve the Lambda Web Adapter layer ARN and architecture for this region
125
+ const platform = manifest.platform;
126
+ const layerArn = LAMBDA_ADAPTER_LAYER[platform]?.replace('${region}', config.aws.region);
127
+ if (!layerArn) throw new Error(`No Lambda Web Adapter layer for platform: ${platform}`);
128
+ const architecture = FRAMEWORK_ARCHITECTURES[platform];
129
+ if (!architecture) throw new Error(`No Lambda architecture for platform: ${platform}`);
130
+
131
+ // The Web Adapter layer uses AWS_LAMBDA_EXEC_WRAPPER to intercept the
132
+ // Lambda runtime bootstrap. It starts run.sh (the web server) and proxies
133
+ // Lambda invocations to it as HTTP requests. The handler value is not
134
+ // called directly but must point to a valid file to pass validation.
135
+ return {
136
+ codeS3Key: repackagedKey,
137
+ runtime,
138
+ handler: 'run.sh',
139
+ layers: [layerArn],
140
+ architectures: [architecture],
141
+ environment: {
142
+ AWS_LAMBDA_EXEC_WRAPPER: '/opt/bootstrap',
143
+ AWS_LWA_PORT: String(port),
144
+ },
145
+ };
146
+ }
147
+
148
+ private detectFramework(tmpDir: string): string {
149
+ if (fs.existsSync(path.join(tmpDir, 'package.json'))) return 'node';
150
+ if (fs.existsSync(path.join(tmpDir, 'requirements.txt'))) return 'python';
151
+ if (fs.existsSync(path.join(tmpDir, 'Pipfile'))) return 'python';
152
+ if (fs.existsSync(path.join(tmpDir, 'pyproject.toml'))) return 'python';
153
+ return 'node';
154
+ }
155
+
156
+ private defaultStartCommand(framework: string, tmpDir: string): string {
157
+ if (framework === 'python') {
158
+ return 'python app.py';
159
+ }
160
+ // Node default
161
+ if (fs.existsSync(path.join(tmpDir, 'package.json'))) {
162
+ const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'));
163
+ if (pkg.scripts?.start) return 'npm start';
164
+ }
165
+ return 'node index.js';
166
+ }
167
+
67
168
  private async resolveContainer(
68
169
  tmpDir: string,
69
170
  manifest: ApplianceContainer,