@ts-cloud/core 0.5.2 → 0.5.4
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/bun-bootstrap +5 -0
- package/dist/index.js +13 -0
- package/dist/node-bootstrap +5 -0
- package/dist/runtime/adapter.ts +287 -0
- package/dist/runtime-assets/bootstrap +39 -0
- package/dist/runtime-assets/cli-runtime.php +148 -0
- package/dist/runtime-assets/fastcgi-client.php +118 -0
- package/dist/runtime-assets/octane-runtime.php +184 -0
- package/dist/runtime-assets/runtime.php +207 -0
- package/dist/runtime.mjs +95 -0
- package/dist/types.d.ts +3 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -45930,9 +45930,12 @@ function composeServerlessAppTemplate(opts) {
|
|
|
45930
45930
|
};
|
|
45931
45931
|
}
|
|
45932
45932
|
if (hasVpc && needsDataVpc) {
|
|
45933
|
+
if (!app.vpc?.id)
|
|
45934
|
+
throw new Error("serverless app: data services (elasticache / aurora-serverless / rdsProxy / efs) need app.vpc.id (the VPC id) so the managed security group is created in the right VPC.");
|
|
45933
45935
|
resources.DataSecurityGroup = {
|
|
45934
45936
|
Type: "AWS::EC2::SecurityGroup",
|
|
45935
45937
|
Properties: {
|
|
45938
|
+
VpcId: app.vpc.id,
|
|
45936
45939
|
GroupDescription: `${slug}-${environment} serverless data access`,
|
|
45937
45940
|
SecurityGroupIngress: [{ IpProtocol: "-1", CidrIp: "10.0.0.0/8" }]
|
|
45938
45941
|
}
|
|
@@ -46013,6 +46016,7 @@ function composeServerlessAppTemplate(opts) {
|
|
|
46013
46016
|
Engine: "aurora-mysql",
|
|
46014
46017
|
EngineMode: "provisioned",
|
|
46015
46018
|
DBClusterIdentifier: `${slug}-${environment}-db`,
|
|
46019
|
+
DatabaseName: "app",
|
|
46016
46020
|
MasterUsername: Fn2.sub("{{resolve:secretsmanager:${DbSecret}:SecretString:username}}"),
|
|
46017
46021
|
MasterUserPassword: Fn2.sub("{{resolve:secretsmanager:${DbSecret}:SecretString:password}}"),
|
|
46018
46022
|
ServerlessV2ScalingConfiguration: { MinCapacity: 0.5, MaxCapacity: 4 },
|
|
@@ -46059,6 +46063,15 @@ function composeServerlessAppTemplate(opts) {
|
|
|
46059
46063
|
RequireTLS: false
|
|
46060
46064
|
}
|
|
46061
46065
|
};
|
|
46066
|
+
resources.DbProxyTargetGroup = {
|
|
46067
|
+
Type: "AWS::RDS::DBProxyTargetGroup",
|
|
46068
|
+
DependsOn: ["DbInstance"],
|
|
46069
|
+
Properties: {
|
|
46070
|
+
DBProxyName: Fn2.ref("DbProxy"),
|
|
46071
|
+
TargetGroupName: "default",
|
|
46072
|
+
DBClusterIdentifiers: [Fn2.ref("DbCluster")]
|
|
46073
|
+
}
|
|
46074
|
+
};
|
|
46062
46075
|
outputs.DbProxyEndpoint = { Description: "RDS Proxy endpoint", Value: Fn2.getAtt("DbProxy", "Endpoint") };
|
|
46063
46076
|
}
|
|
46064
46077
|
outputs.HttpFunctionName = { Description: "HTTP function name", Value: Fn2.ref("HttpFunction") };
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serverless runtime adapter (Node/Bun).
|
|
3
|
+
*
|
|
4
|
+
* This module is **bundled into the Lambda deployment artifact** and runs inside
|
|
5
|
+
* the AWS Lambda Node.js runtime, so it must rely only on globals available
|
|
6
|
+
* there: `Request`, `Response`, `Headers`, `URL`, `Buffer`, `atob`/`btoa`.
|
|
7
|
+
* It contains no AWS SDK calls and no Node-only filesystem access.
|
|
8
|
+
*
|
|
9
|
+
* It translates the three event sources of a Vapor-style serverless app into
|
|
10
|
+
* plain, framework-agnostic callbacks:
|
|
11
|
+
* - HTTP : API Gateway v2 (payload format 2.0) ⇄ WHATWG `Request`/`Response`
|
|
12
|
+
* - Queue : SQS records → per-message job handler with partial-batch failures
|
|
13
|
+
* - CLI : `{ command, args }` → command handler (scheduler / migrations)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── Event/response shapes (minimal subsets of the AWS types) ────────────────
|
|
17
|
+
|
|
18
|
+
export interface ApiGatewayProxyEventV2 {
|
|
19
|
+
version: '2.0'
|
|
20
|
+
rawPath: string
|
|
21
|
+
rawQueryString?: string
|
|
22
|
+
cookies?: string[]
|
|
23
|
+
headers?: Record<string, string | undefined>
|
|
24
|
+
body?: string
|
|
25
|
+
isBase64Encoded?: boolean
|
|
26
|
+
requestContext: {
|
|
27
|
+
domainName?: string
|
|
28
|
+
http: {
|
|
29
|
+
method: string
|
|
30
|
+
path: string
|
|
31
|
+
protocol?: string
|
|
32
|
+
sourceIp?: string
|
|
33
|
+
userAgent?: string
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApiGatewayProxyResultV2 {
|
|
39
|
+
statusCode: number
|
|
40
|
+
headers?: Record<string, string>
|
|
41
|
+
cookies?: string[]
|
|
42
|
+
body?: string
|
|
43
|
+
isBase64Encoded?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SqsRecord {
|
|
47
|
+
messageId: string
|
|
48
|
+
receiptHandle?: string
|
|
49
|
+
body: string
|
|
50
|
+
attributes?: Record<string, string>
|
|
51
|
+
messageAttributes?: Record<string, unknown>
|
|
52
|
+
eventSourceARN?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SqsEvent {
|
|
56
|
+
Records: SqsRecord[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SqsBatchResponse {
|
|
60
|
+
batchItemFailures: Array<{ itemIdentifier: string }>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CliEvent {
|
|
64
|
+
command: string
|
|
65
|
+
args?: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CliResult {
|
|
69
|
+
statusCode: number
|
|
70
|
+
output: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Handler callback contracts ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export type FetchHandler = (request: Request) => Response | Promise<Response>
|
|
76
|
+
export type JobHandler = (payload: unknown, record: SqsRecord) => unknown | Promise<unknown>
|
|
77
|
+
export type CommandHandler = (event: CliEvent) => CliResult | Promise<CliResult>
|
|
78
|
+
|
|
79
|
+
/** Lambda handler invoked by API Gateway v2. */
|
|
80
|
+
export type LambdaHttpHandler = (event: ApiGatewayProxyEventV2) => Promise<ApiGatewayProxyResultV2>
|
|
81
|
+
/** Lambda handler invoked by an SQS event source mapping. */
|
|
82
|
+
export type LambdaQueueHandler = (event: SqsEvent) => Promise<SqsBatchResponse>
|
|
83
|
+
/** Lambda handler invoked by EventBridge / on-demand for CLI commands. */
|
|
84
|
+
export type LambdaCliHandler = (event: CliEvent) => Promise<CliResult>
|
|
85
|
+
|
|
86
|
+
export interface ServerlessApp {
|
|
87
|
+
fetch?: FetchHandler
|
|
88
|
+
queue?: JobHandler
|
|
89
|
+
cli?: CommandHandler
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Normalize whatever the user's entry module exports into a {@link ServerlessApp}.
|
|
94
|
+
* Accepts a bare fetch function, an object with `fetch`/`queue`/`cli`, or a
|
|
95
|
+
* Bun.serve-style `{ default: { fetch } }`.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveApp(mod: unknown): ServerlessApp {
|
|
98
|
+
const m = mod as Record<string, unknown>
|
|
99
|
+
const def = (m?.default ?? m) as Record<string, unknown>
|
|
100
|
+
if (typeof def === 'function') return { fetch: def as FetchHandler }
|
|
101
|
+
return {
|
|
102
|
+
fetch: (def?.fetch ?? m?.fetch) as FetchHandler | undefined,
|
|
103
|
+
queue: (def?.queue ?? m?.queue) as JobHandler | undefined,
|
|
104
|
+
cli: (def?.cli ?? m?.cli) as CommandHandler | undefined,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Body / content helpers ──────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const TEXT_CONTENT = /^(?:text\/|application\/(?:json|xml|javascript|graphql|x-www-form-urlencoded|.*\+json|.*\+xml)|image\/svg)/i
|
|
111
|
+
|
|
112
|
+
function isTextContentType(contentType: string | null): boolean {
|
|
113
|
+
// No declared type → treat as text (the common case for `new Response(str)`).
|
|
114
|
+
// Binary payloads in practice always carry an explicit content-type.
|
|
115
|
+
if (!contentType) return true
|
|
116
|
+
return TEXT_CONTENT.test(contentType)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── HTTP ────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface HttpAdapterOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Read maintenance state from the environment. When `MAINTENANCE_MODE` is
|
|
124
|
+
* truthy, requests get a 503 unless they carry the bypass secret in the
|
|
125
|
+
* `x-maintenance-bypass` header or a `tscloud_bypass` cookie.
|
|
126
|
+
*/
|
|
127
|
+
maintenance?: {
|
|
128
|
+
enabled: boolean
|
|
129
|
+
bypassSecret?: string
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readMaintenance(opts?: HttpAdapterOptions): { enabled: boolean, bypassSecret?: string } {
|
|
134
|
+
if (opts?.maintenance) return opts.maintenance
|
|
135
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
|
|
136
|
+
return {
|
|
137
|
+
enabled: env.MAINTENANCE_MODE === '1' || env.MAINTENANCE_MODE === 'true',
|
|
138
|
+
bypassSecret: env.MAINTENANCE_BYPASS_SECRET,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Translate an API Gateway v2 event into a WHATWG `Request`. */
|
|
143
|
+
export function eventToRequest(event: ApiGatewayProxyEventV2): Request {
|
|
144
|
+
const host = event.requestContext?.domainName ?? 'localhost'
|
|
145
|
+
const query = event.rawQueryString ? `?${event.rawQueryString}` : ''
|
|
146
|
+
const url = `https://${host}${event.rawPath || '/'}${query}`
|
|
147
|
+
|
|
148
|
+
const headers = new Headers()
|
|
149
|
+
for (const [key, value] of Object.entries(event.headers ?? {})) {
|
|
150
|
+
if (value !== undefined) headers.set(key, value)
|
|
151
|
+
}
|
|
152
|
+
if (event.cookies?.length) headers.set('cookie', event.cookies.join('; '))
|
|
153
|
+
|
|
154
|
+
const method = event.requestContext.http.method
|
|
155
|
+
let body: Uint8Array | undefined
|
|
156
|
+
if (event.body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
157
|
+
body = event.isBase64Encoded
|
|
158
|
+
? new Uint8Array(Buffer.from(event.body, 'base64'))
|
|
159
|
+
: new TextEncoder().encode(event.body)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return new Request(url, { method, headers, body })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Serialize a WHATWG `Response` into an API Gateway v2 result. */
|
|
166
|
+
export async function responseToResult(response: Response): Promise<ApiGatewayProxyResultV2> {
|
|
167
|
+
const headers: Record<string, string> = {}
|
|
168
|
+
const cookies: string[] = []
|
|
169
|
+
|
|
170
|
+
// `getSetCookie` is available in the Lambda runtime's undici Headers.
|
|
171
|
+
const setCookies = typeof (response.headers as { getSetCookie?: () => string[] }).getSetCookie === 'function'
|
|
172
|
+
? (response.headers as { getSetCookie: () => string[] }).getSetCookie()
|
|
173
|
+
: []
|
|
174
|
+
for (const c of setCookies) cookies.push(c)
|
|
175
|
+
|
|
176
|
+
response.headers.forEach((value, key) => {
|
|
177
|
+
if (key.toLowerCase() === 'set-cookie') return
|
|
178
|
+
headers[key] = value
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
182
|
+
const textual = isTextContentType(response.headers.get('content-type'))
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
statusCode: response.status,
|
|
186
|
+
headers,
|
|
187
|
+
...(cookies.length ? { cookies } : {}),
|
|
188
|
+
body: textual ? buffer.toString('utf-8') : buffer.toString('base64'),
|
|
189
|
+
isBase64Encoded: !textual,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Wrap a fetch-style handler into a Lambda HTTP handler for API Gateway v2.
|
|
195
|
+
* Honors maintenance mode (503 + bypass) before invoking the app.
|
|
196
|
+
*/
|
|
197
|
+
export function createHttpHandler(handler: FetchHandler | undefined, opts?: HttpAdapterOptions): LambdaHttpHandler {
|
|
198
|
+
return async (event: ApiGatewayProxyEventV2): Promise<ApiGatewayProxyResultV2> => {
|
|
199
|
+
// Warmer pings (from the scheduled keep-warm rule) just keep the container
|
|
200
|
+
// alive — short-circuit before treating the event as an HTTP request.
|
|
201
|
+
if ((event as unknown as { warmer?: boolean }).warmer) {
|
|
202
|
+
return { statusCode: 200, headers: { 'content-type': 'text/plain' }, body: 'warm', isBase64Encoded: false }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!handler) {
|
|
206
|
+
return { statusCode: 501, headers: { 'content-type': 'text/plain' }, body: 'No HTTP handler configured', isBase64Encoded: false }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const maintenance = readMaintenance(opts)
|
|
210
|
+
if (maintenance.enabled) {
|
|
211
|
+
const bypass = event.headers?.['x-maintenance-bypass']
|
|
212
|
+
?? (event.cookies?.find(c => c.startsWith('tscloud_bypass='))?.split('=')[1])
|
|
213
|
+
if (!maintenance.bypassSecret || bypass !== maintenance.bypassSecret) {
|
|
214
|
+
return {
|
|
215
|
+
statusCode: 503,
|
|
216
|
+
headers: { 'content-type': 'text/plain', 'retry-after': '120' },
|
|
217
|
+
body: 'Service temporarily unavailable (maintenance mode)',
|
|
218
|
+
isBase64Encoded: false,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const request = eventToRequest(event)
|
|
224
|
+
const response = await handler(request)
|
|
225
|
+
return responseToResult(response)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Queue ─────────────────────────────────────────────────────────────────--
|
|
230
|
+
|
|
231
|
+
function parseRecordBody(body: string): unknown {
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(body)
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return body
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Wrap a job handler into a Lambda SQS handler. Each record is processed
|
|
242
|
+
* individually; failures are reported via `batchItemFailures` so only failed
|
|
243
|
+
* messages are retried (requires `ReportBatchItemFailures` on the mapping).
|
|
244
|
+
*/
|
|
245
|
+
export function createQueueHandler(handler: JobHandler | undefined): LambdaQueueHandler {
|
|
246
|
+
return async (event: SqsEvent): Promise<SqsBatchResponse> => {
|
|
247
|
+
const batchItemFailures: Array<{ itemIdentifier: string }> = []
|
|
248
|
+
if (!handler) return { batchItemFailures }
|
|
249
|
+
|
|
250
|
+
for (const record of event.Records ?? []) {
|
|
251
|
+
try {
|
|
252
|
+
await handler(parseRecordBody(record.body), record)
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
batchItemFailures.push({ itemIdentifier: record.messageId })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { batchItemFailures }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── CLI / scheduler ─────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wrap a command handler into a Lambda CLI handler. Used by the EventBridge
|
|
266
|
+
* scheduler (`{ command: 'schedule:run' }`) and on-demand invocations
|
|
267
|
+
* (deploy hooks, migrations, `cloud command`).
|
|
268
|
+
*/
|
|
269
|
+
export function createCliHandler(handler: CommandHandler | undefined): LambdaCliHandler {
|
|
270
|
+
return async (event: CliEvent): Promise<CliResult> => {
|
|
271
|
+
if (!handler) return { statusCode: 501, output: 'No CLI handler configured' }
|
|
272
|
+
return handler(event)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Convenience: build all three Lambda handlers from a resolved app. */
|
|
277
|
+
export function createHandlers(app: ServerlessApp, opts?: HttpAdapterOptions): {
|
|
278
|
+
http: LambdaHttpHandler
|
|
279
|
+
queue: LambdaQueueHandler
|
|
280
|
+
cli: LambdaCliHandler
|
|
281
|
+
} {
|
|
282
|
+
return {
|
|
283
|
+
http: createHttpHandler(app.fetch, opts),
|
|
284
|
+
queue: createQueueHandler(app.queue),
|
|
285
|
+
cli: createCliHandler(app.cli),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# ts-cloud PHP custom runtime bootstrap for AWS Lambda (provided.al2023).
|
|
3
|
+
#
|
|
4
|
+
# Selects the runtime mode from TSCLOUD_LAMBDA_MODE:
|
|
5
|
+
# http -> start php-fpm and run the FastCGI bridge loop (runtime.php)
|
|
6
|
+
# queue -> SQS worker loop (cli-runtime.php)
|
|
7
|
+
# cli -> scheduler / on-demand artisan commands (cli-runtime.php)
|
|
8
|
+
#
|
|
9
|
+
# The PHP binary + extensions + these scripts are provided by the ts-cloud PHP
|
|
10
|
+
# runtime layer mounted at /opt.
|
|
11
|
+
set -e
|
|
12
|
+
|
|
13
|
+
export PATH="/opt/bin:/opt/php/bin:/opt/php/sbin:${PATH}"
|
|
14
|
+
export LD_LIBRARY_PATH="/opt/php/lib:${LD_LIBRARY_PATH}"
|
|
15
|
+
# The relocated PHP's compiled-in ini path points at the build-time SCL location,
|
|
16
|
+
# so point it at the layer's ini (sets extension_dir + loads every extension).
|
|
17
|
+
export PHPRC="/opt/php/etc/php.ini"
|
|
18
|
+
|
|
19
|
+
# Only /tmp is writable on Lambda; pre-create Laravel's runtime directories.
|
|
20
|
+
mkdir -p \
|
|
21
|
+
/tmp/storage/framework/cache/data \
|
|
22
|
+
/tmp/storage/framework/sessions \
|
|
23
|
+
/tmp/storage/framework/views \
|
|
24
|
+
/tmp/storage/logs \
|
|
25
|
+
/tmp/bootstrap/cache
|
|
26
|
+
|
|
27
|
+
MODE="${TSCLOUD_LAMBDA_MODE:-http}"
|
|
28
|
+
|
|
29
|
+
if [ "$MODE" = "http" ]; then
|
|
30
|
+
if [ "${TSCLOUD_OCTANE:-0}" = "1" ]; then
|
|
31
|
+
# Persistent mode: boot Laravel once, serve in-process (no php-fpm).
|
|
32
|
+
exec php /opt/tscloud/octane-runtime.php
|
|
33
|
+
fi
|
|
34
|
+
# Launch php-fpm (listening on a unix socket in /tmp) then run the bridge loop.
|
|
35
|
+
php-fpm --nodaemonize --fpm-config /opt/tscloud/php-fpm.conf &
|
|
36
|
+
exec php /opt/tscloud/runtime.php
|
|
37
|
+
else
|
|
38
|
+
exec php /opt/tscloud/cli-runtime.php
|
|
39
|
+
fi
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* ts-cloud PHP Lambda CLI runtime — handles the queue, scheduler, and cli modes.
|
|
4
|
+
*
|
|
5
|
+
* Implements the Lambda Runtime API loop and dispatches based on the event:
|
|
6
|
+
* - SQS event ({Records:[...]}) -> one artisan queue job per record
|
|
7
|
+
* - EventBridge / {command:'schedule:run'} -> `php artisan schedule:run`
|
|
8
|
+
* - {command:'<artisan command>'} -> arbitrary artisan command
|
|
9
|
+
*
|
|
10
|
+
* Queue records are passed to a Laravel bridge command (default
|
|
11
|
+
* `tscloud:sqs-handle`, override via TSCLOUD_QUEUE_COMMAND) provided by the
|
|
12
|
+
* `tscloud/serverless` composer package. This avoids the double-delivery hazard
|
|
13
|
+
* of running stock `queue:work` (which would re-poll SQS alongside Lambda's own
|
|
14
|
+
* poller).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
$runtimeApi = getenv('AWS_LAMBDA_RUNTIME_API');
|
|
18
|
+
$taskRoot = getenv('LAMBDA_TASK_ROOT') ?: '/var/task';
|
|
19
|
+
$artisan = $taskRoot . '/artisan';
|
|
20
|
+
$queueCommand = getenv('TSCLOUD_QUEUE_COMMAND') ?: 'tscloud:sqs-handle';
|
|
21
|
+
|
|
22
|
+
while (true) {
|
|
23
|
+
$ctx = nextInvocation($runtimeApi);
|
|
24
|
+
if ($ctx === null) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
[$requestId, $event] = $ctx;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
$result = dispatch($event, $artisan, $queueCommand);
|
|
31
|
+
postResponse($runtimeApi, $requestId, $result);
|
|
32
|
+
} catch (\Throwable $e) {
|
|
33
|
+
postError($runtimeApi, $requestId, $e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dispatch(array $event, string $artisan, string $queueCommand): array
|
|
38
|
+
{
|
|
39
|
+
// SQS queue event.
|
|
40
|
+
if (isset($event['Records']) && is_array($event['Records'])) {
|
|
41
|
+
$failures = [];
|
|
42
|
+
foreach ($event['Records'] as $record) {
|
|
43
|
+
$body = $record['body'] ?? '';
|
|
44
|
+
$messageId = $record['messageId'] ?? '';
|
|
45
|
+
$exit = runArtisan($artisan, [$queueCommand], ['TSCLOUD_SQS_RECORD' => $body], $out);
|
|
46
|
+
if ($exit !== 0) {
|
|
47
|
+
$failures[] = ['itemIdentifier' => $messageId];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return ['batchItemFailures' => $failures];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Scheduler / arbitrary command.
|
|
54
|
+
$command = $event['command'] ?? 'schedule:run';
|
|
55
|
+
|
|
56
|
+
// Sub-minute scheduling: EventBridge fires once a minute, so loop
|
|
57
|
+
// `schedule:run` within the invocation (~every 10s for ~55s) to honor tasks
|
|
58
|
+
// scheduled more frequently than once a minute.
|
|
59
|
+
if ($command === 'schedule:run' && getenv('TSCLOUD_SCHEDULER') === 'sub-minute') {
|
|
60
|
+
$deadline = time() + 55;
|
|
61
|
+
$last = '';
|
|
62
|
+
do {
|
|
63
|
+
runArtisan($artisan, ['schedule:run'], [], $last);
|
|
64
|
+
if (time() < $deadline) {
|
|
65
|
+
sleep(10);
|
|
66
|
+
}
|
|
67
|
+
} while (time() < $deadline);
|
|
68
|
+
return ['statusCode' => 0, 'output' => $last];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$args = preg_split('/\s+/', trim($command));
|
|
72
|
+
$exit = runArtisan($artisan, $args, [], $out);
|
|
73
|
+
return ['statusCode' => $exit, 'output' => $out];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param string[] $args
|
|
78
|
+
* @param array<string,string> $env
|
|
79
|
+
*/
|
|
80
|
+
function runArtisan(string $artisan, array $args, array $env, ?string &$out): int
|
|
81
|
+
{
|
|
82
|
+
$cmd = array_merge(['php', $artisan], $args);
|
|
83
|
+
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
|
84
|
+
$fullEnv = array_merge(getenv(), $env);
|
|
85
|
+
$proc = proc_open($cmd, $descriptors, $pipes, getenv('LAMBDA_TASK_ROOT') ?: '/var/task', $fullEnv);
|
|
86
|
+
if (!is_resource($proc)) {
|
|
87
|
+
$out = 'failed to start artisan';
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
$stdout = stream_get_contents($pipes[1]);
|
|
91
|
+
$stderr = stream_get_contents($pipes[2]);
|
|
92
|
+
fclose($pipes[1]);
|
|
93
|
+
fclose($pipes[2]);
|
|
94
|
+
$exit = proc_close($proc);
|
|
95
|
+
$out = trim($stdout . "\n" . $stderr);
|
|
96
|
+
// Surface output to CloudWatch.
|
|
97
|
+
fwrite(STDERR, $out . "\n");
|
|
98
|
+
return $exit;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @return array{0:string,1:array}|null
|
|
103
|
+
*/
|
|
104
|
+
function nextInvocation(string $api): ?array
|
|
105
|
+
{
|
|
106
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/next");
|
|
107
|
+
$headers = [];
|
|
108
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
109
|
+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($c, $h) use (&$headers) {
|
|
110
|
+
$parts = explode(':', $h, 2);
|
|
111
|
+
if (count($parts) === 2) {
|
|
112
|
+
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
|
|
113
|
+
}
|
|
114
|
+
return strlen($h);
|
|
115
|
+
});
|
|
116
|
+
$body = curl_exec($ch);
|
|
117
|
+
curl_close($ch);
|
|
118
|
+
if ($body === false) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
$requestId = $headers['lambda-runtime-aws-request-id'] ?? '';
|
|
122
|
+
$event = json_decode($body, true) ?: [];
|
|
123
|
+
return [$requestId, $event];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function postResponse(string $api, string $requestId, array $response): void
|
|
127
|
+
{
|
|
128
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/response");
|
|
129
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
130
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
131
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($response));
|
|
132
|
+
curl_exec($ch);
|
|
133
|
+
curl_close($ch);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function postError(string $api, string $requestId, \Throwable $e): void
|
|
137
|
+
{
|
|
138
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/error");
|
|
139
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
140
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
141
|
+
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
142
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
143
|
+
'errorType' => get_class($e),
|
|
144
|
+
'errorMessage' => $e->getMessage(),
|
|
145
|
+
]));
|
|
146
|
+
curl_exec($ch);
|
|
147
|
+
curl_close($ch);
|
|
148
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Minimal FastCGI client for the ts-cloud PHP Lambda runtime.
|
|
4
|
+
*
|
|
5
|
+
* Speaks just enough of the FastCGI protocol to send one request (params + stdin)
|
|
6
|
+
* to php-fpm over a unix socket and read the full stdout/stderr response. This is
|
|
7
|
+
* the bridge between the Lambda Runtime API loop and Laravel's public/index.php
|
|
8
|
+
* served by php-fpm.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
namespace TsCloud;
|
|
12
|
+
|
|
13
|
+
final class FastCgiClient
|
|
14
|
+
{
|
|
15
|
+
const FCGI_VERSION = 1;
|
|
16
|
+
const BEGIN_REQUEST = 1;
|
|
17
|
+
const END_REQUEST = 3;
|
|
18
|
+
const PARAMS = 4;
|
|
19
|
+
const STDIN = 5;
|
|
20
|
+
const STDOUT = 6;
|
|
21
|
+
const STDERR = 7;
|
|
22
|
+
const RESPONDER = 1;
|
|
23
|
+
|
|
24
|
+
private string $socketPath;
|
|
25
|
+
|
|
26
|
+
public function __construct(string $socketPath)
|
|
27
|
+
{
|
|
28
|
+
$this->socketPath = $socketPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param array<string,string> $params FastCGI params (CGI environment)
|
|
33
|
+
* @return array{stdout:string,stderr:string}
|
|
34
|
+
*/
|
|
35
|
+
public function request(array $params, string $stdin = ''): array
|
|
36
|
+
{
|
|
37
|
+
$conn = @stream_socket_client('unix://' . $this->socketPath, $errno, $errstr, 30);
|
|
38
|
+
if ($conn === false) {
|
|
39
|
+
throw new \RuntimeException("FastCGI connect failed: {$errstr} ({$errno})");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
$requestId = 1;
|
|
43
|
+
|
|
44
|
+
// BEGIN_REQUEST (role=RESPONDER, flags=0 -> close connection when done).
|
|
45
|
+
$beginBody = pack('nCxxxxx', self::RESPONDER, 0);
|
|
46
|
+
fwrite($conn, $this->record(self::BEGIN_REQUEST, $requestId, $beginBody));
|
|
47
|
+
|
|
48
|
+
// PARAMS (name-value pairs), then an empty PARAMS record to terminate.
|
|
49
|
+
$paramsBody = '';
|
|
50
|
+
foreach ($params as $name => $value) {
|
|
51
|
+
$paramsBody .= $this->nameValuePair((string) $name, (string) $value);
|
|
52
|
+
}
|
|
53
|
+
if ($paramsBody !== '') {
|
|
54
|
+
fwrite($conn, $this->record(self::PARAMS, $requestId, $paramsBody));
|
|
55
|
+
}
|
|
56
|
+
fwrite($conn, $this->record(self::PARAMS, $requestId, ''));
|
|
57
|
+
|
|
58
|
+
// STDIN (body) then an empty STDIN record to terminate.
|
|
59
|
+
if ($stdin !== '') {
|
|
60
|
+
foreach (str_split($stdin, 65535) as $chunk) {
|
|
61
|
+
fwrite($conn, $this->record(self::STDIN, $requestId, $chunk));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fwrite($conn, $this->record(self::STDIN, $requestId, ''));
|
|
65
|
+
|
|
66
|
+
// Read the response records.
|
|
67
|
+
$stdout = '';
|
|
68
|
+
$stderr = '';
|
|
69
|
+
while (!feof($conn)) {
|
|
70
|
+
$header = fread($conn, 8);
|
|
71
|
+
if ($header === false || strlen($header) < 8) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
$h = unpack('Cversion/Ctype/nrequestId/ncontentLength/CpaddingLength/Creserved', $header);
|
|
75
|
+
$content = '';
|
|
76
|
+
$remaining = $h['contentLength'];
|
|
77
|
+
while ($remaining > 0) {
|
|
78
|
+
$buf = fread($conn, $remaining);
|
|
79
|
+
if ($buf === false || $buf === '') {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
$content .= $buf;
|
|
83
|
+
$remaining -= strlen($buf);
|
|
84
|
+
}
|
|
85
|
+
if ($h['paddingLength'] > 0) {
|
|
86
|
+
fread($conn, $h['paddingLength']);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ($h['type'] === self::STDOUT) {
|
|
90
|
+
$stdout .= $content;
|
|
91
|
+
} elseif ($h['type'] === self::STDERR) {
|
|
92
|
+
$stderr .= $content;
|
|
93
|
+
} elseif ($h['type'] === self::END_REQUEST) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fclose($conn);
|
|
99
|
+
|
|
100
|
+
return ['stdout' => $stdout, 'stderr' => $stderr];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private function record(int $type, int $requestId, string $content): string
|
|
104
|
+
{
|
|
105
|
+
$length = strlen($content);
|
|
106
|
+
$header = pack('CCnnCx', self::FCGI_VERSION, $type, $requestId, $length, 0);
|
|
107
|
+
return $header . $content;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private function nameValuePair(string $name, string $value): string
|
|
111
|
+
{
|
|
112
|
+
$nlen = strlen($name);
|
|
113
|
+
$vlen = strlen($value);
|
|
114
|
+
$out = $nlen < 128 ? chr($nlen) : pack('N', $nlen | 0x80000000);
|
|
115
|
+
$out .= $vlen < 128 ? chr($vlen) : pack('N', $vlen | 0x80000000);
|
|
116
|
+
return $out . $name . $value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* ts-cloud PHP Lambda HTTP runtime — Octane / persistent mode.
|
|
4
|
+
*
|
|
5
|
+
* Boots the Laravel application ONCE per cold start and serves each invocation
|
|
6
|
+
* in-process through the HTTP kernel (no php-fpm, no FastCGI hop), then resets
|
|
7
|
+
* request-scoped state between invocations. Lower latency than the FPM bridge at
|
|
8
|
+
* the cost of requiring an Octane-safe app. Selected when TSCLOUD_OCTANE=1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
$runtimeApi = getenv('AWS_LAMBDA_RUNTIME_API');
|
|
12
|
+
$taskRoot = getenv('LAMBDA_TASK_ROOT') ?: '/var/task';
|
|
13
|
+
|
|
14
|
+
require $taskRoot . '/vendor/autoload.php';
|
|
15
|
+
|
|
16
|
+
// Boot the application + HTTP kernel once.
|
|
17
|
+
$app = require $taskRoot . '/bootstrap/app.php';
|
|
18
|
+
$kernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);
|
|
19
|
+
|
|
20
|
+
$maintenance = getenv('MAINTENANCE_MODE') === '1';
|
|
21
|
+
$bypassSecret = getenv('MAINTENANCE_BYPASS_SECRET') ?: '';
|
|
22
|
+
|
|
23
|
+
while (true) {
|
|
24
|
+
$ctx = nextInvocation($runtimeApi);
|
|
25
|
+
if ($ctx === null) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
[$requestId, $event] = $ctx;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
$response = handle($event, $kernel, $maintenance, $bypassSecret);
|
|
32
|
+
postResponse($runtimeApi, $requestId, $response);
|
|
33
|
+
} catch (\Throwable $e) {
|
|
34
|
+
postError($runtimeApi, $requestId, $e);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handle(array $event, $kernel, bool $maintenance, string $bypassSecret): array
|
|
39
|
+
{
|
|
40
|
+
if (($event['warmer'] ?? false) === true) {
|
|
41
|
+
return ['statusCode' => 200, 'headers' => ['content-type' => 'text/plain'], 'body' => 'warm', 'isBase64Encoded' => false];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$http = $event['requestContext']['http'] ?? [];
|
|
45
|
+
$headers = $event['headers'] ?? [];
|
|
46
|
+
|
|
47
|
+
if ($maintenance) {
|
|
48
|
+
$bypass = $headers['x-maintenance-bypass'] ?? '';
|
|
49
|
+
if ($bypassSecret === '' || $bypass !== $bypassSecret) {
|
|
50
|
+
return ['statusCode' => 503, 'headers' => ['content-type' => 'text/plain', 'retry-after' => '120'], 'body' => 'Service temporarily unavailable (maintenance mode)', 'isBase64Encoded' => false];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
$body = $event['body'] ?? '';
|
|
55
|
+
if (($event['isBase64Encoded'] ?? false) === true) {
|
|
56
|
+
$body = base64_decode($body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
$method = $http['method'] ?? 'GET';
|
|
60
|
+
$rawPath = $event['rawPath'] ?? '/';
|
|
61
|
+
$rawQuery = $event['rawQueryString'] ?? '';
|
|
62
|
+
|
|
63
|
+
// Build server vars + an Illuminate request.
|
|
64
|
+
$server = [
|
|
65
|
+
'REQUEST_METHOD' => $method,
|
|
66
|
+
'REQUEST_URI' => $rawQuery !== '' ? $rawPath . '?' . $rawQuery : $rawPath,
|
|
67
|
+
'QUERY_STRING' => $rawQuery,
|
|
68
|
+
'SERVER_NAME' => $event['requestContext']['domainName'] ?? 'localhost',
|
|
69
|
+
'SERVER_PORT' => '443',
|
|
70
|
+
'HTTPS' => 'on',
|
|
71
|
+
'REMOTE_ADDR' => $http['sourceIp'] ?? '127.0.0.1',
|
|
72
|
+
];
|
|
73
|
+
foreach ($headers as $name => $value) {
|
|
74
|
+
$server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value;
|
|
75
|
+
if (strtolower($name) === 'content-type') {
|
|
76
|
+
$server['CONTENT_TYPE'] = $value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
parse_str($rawQuery, $query);
|
|
81
|
+
$cookies = [];
|
|
82
|
+
foreach ($event['cookies'] ?? [] as $cookie) {
|
|
83
|
+
$parts = explode('=', $cookie, 2);
|
|
84
|
+
if (count($parts) === 2) {
|
|
85
|
+
$cookies[$parts[0]] = urldecode($parts[1]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
$request = new \Illuminate\Http\Request(
|
|
90
|
+
$query,
|
|
91
|
+
[],
|
|
92
|
+
[],
|
|
93
|
+
$cookies,
|
|
94
|
+
[],
|
|
95
|
+
$server,
|
|
96
|
+
$body
|
|
97
|
+
);
|
|
98
|
+
$request->setMethod($method);
|
|
99
|
+
|
|
100
|
+
$response = $kernel->handle($request);
|
|
101
|
+
$result = marshalResponse($response);
|
|
102
|
+
$kernel->terminate($request, $response);
|
|
103
|
+
|
|
104
|
+
return $result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function marshalResponse($response): array
|
|
108
|
+
{
|
|
109
|
+
$content = $response->getContent();
|
|
110
|
+
$headers = [];
|
|
111
|
+
$cookies = [];
|
|
112
|
+
foreach ($response->headers->allPreserveCase() as $name => $values) {
|
|
113
|
+
if (strtolower($name) === 'set-cookie') {
|
|
114
|
+
foreach ($values as $v) {
|
|
115
|
+
$cookies[] = $v;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
$headers[$name] = implode(', ', $values);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
$contentType = $headers['Content-Type'] ?? ($headers['content-type'] ?? 'text/html');
|
|
123
|
+
$isText = (bool) preg_match('#^(text/|application/(json|xml|javascript|x-www-form-urlencoded)|image/svg)#i', $contentType);
|
|
124
|
+
|
|
125
|
+
$out = [
|
|
126
|
+
'statusCode' => $response->getStatusCode(),
|
|
127
|
+
'headers' => $headers,
|
|
128
|
+
'isBase64Encoded' => !$isText,
|
|
129
|
+
'body' => $isText ? $content : base64_encode($content),
|
|
130
|
+
];
|
|
131
|
+
if (!empty($cookies)) {
|
|
132
|
+
$out['cookies'] = $cookies;
|
|
133
|
+
}
|
|
134
|
+
return $out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @return array{0:string,1:array}|null
|
|
139
|
+
*/
|
|
140
|
+
function nextInvocation(string $api): ?array
|
|
141
|
+
{
|
|
142
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/next");
|
|
143
|
+
$headers = [];
|
|
144
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
145
|
+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($c, $h) use (&$headers) {
|
|
146
|
+
$parts = explode(':', $h, 2);
|
|
147
|
+
if (count($parts) === 2) {
|
|
148
|
+
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
|
|
149
|
+
}
|
|
150
|
+
return strlen($h);
|
|
151
|
+
});
|
|
152
|
+
$body = curl_exec($ch);
|
|
153
|
+
curl_close($ch);
|
|
154
|
+
if ($body === false) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
$requestId = $headers['lambda-runtime-aws-request-id'] ?? '';
|
|
158
|
+
$event = json_decode($body, true) ?: [];
|
|
159
|
+
return [$requestId, $event];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function postResponse(string $api, string $requestId, array $response): void
|
|
163
|
+
{
|
|
164
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/response");
|
|
165
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
166
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
167
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($response));
|
|
168
|
+
curl_exec($ch);
|
|
169
|
+
curl_close($ch);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function postError(string $api, string $requestId, \Throwable $e): void
|
|
173
|
+
{
|
|
174
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/error");
|
|
175
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
176
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
177
|
+
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
178
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
179
|
+
'errorType' => get_class($e),
|
|
180
|
+
'errorMessage' => $e->getMessage(),
|
|
181
|
+
]));
|
|
182
|
+
curl_exec($ch);
|
|
183
|
+
curl_close($ch);
|
|
184
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* ts-cloud PHP Lambda HTTP runtime (FPM mode).
|
|
4
|
+
*
|
|
5
|
+
* Implements the AWS Lambda Runtime API loop for the HTTP function: long-poll for
|
|
6
|
+
* the next invocation (an API Gateway v2 payload-format-2.0 event), bridge it to
|
|
7
|
+
* php-fpm over FastCGI (running Laravel's public/index.php), and post the response
|
|
8
|
+
* back in API Gateway v2 response shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
require __DIR__ . '/fastcgi-client.php';
|
|
12
|
+
|
|
13
|
+
use TsCloud\FastCgiClient;
|
|
14
|
+
|
|
15
|
+
$runtimeApi = getenv('AWS_LAMBDA_RUNTIME_API');
|
|
16
|
+
$taskRoot = getenv('LAMBDA_TASK_ROOT') ?: '/var/task';
|
|
17
|
+
$docRoot = $taskRoot . '/public';
|
|
18
|
+
$socketPath = '/tmp/.tscloud-fpm.sock';
|
|
19
|
+
|
|
20
|
+
$fpm = new FastCgiClient($socketPath);
|
|
21
|
+
|
|
22
|
+
// Wait for php-fpm to create its socket (bounded).
|
|
23
|
+
for ($i = 0; $i < 50 && !file_exists($socketPath); $i++) {
|
|
24
|
+
usleep(100000); // 100ms
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
$maintenance = getenv('MAINTENANCE_MODE') === '1';
|
|
28
|
+
$bypassSecret = getenv('MAINTENANCE_BYPASS_SECRET') ?: '';
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
// 1. Get the next invocation.
|
|
32
|
+
$ctx = nextInvocation($runtimeApi);
|
|
33
|
+
if ($ctx === null) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
[$requestId, $event] = $ctx;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
$response = handle($event, $fpm, $docRoot, $maintenance, $bypassSecret);
|
|
40
|
+
postResponse($runtimeApi, $requestId, $response);
|
|
41
|
+
} catch (\Throwable $e) {
|
|
42
|
+
postError($runtimeApi, $requestId, $e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @return array{0:string,1:array}|null
|
|
48
|
+
*/
|
|
49
|
+
function nextInvocation(string $api): ?array
|
|
50
|
+
{
|
|
51
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/next");
|
|
52
|
+
$headers = [];
|
|
53
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
54
|
+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($c, $h) use (&$headers) {
|
|
55
|
+
$parts = explode(':', $h, 2);
|
|
56
|
+
if (count($parts) === 2) {
|
|
57
|
+
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
|
|
58
|
+
}
|
|
59
|
+
return strlen($h);
|
|
60
|
+
});
|
|
61
|
+
$body = curl_exec($ch);
|
|
62
|
+
curl_close($ch);
|
|
63
|
+
if ($body === false) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
$requestId = $headers['lambda-runtime-aws-request-id'] ?? '';
|
|
67
|
+
$event = json_decode($body, true) ?: [];
|
|
68
|
+
return [$requestId, $event];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handle(array $event, FastCgiClient $fpm, string $docRoot, bool $maintenance, string $bypassSecret): array
|
|
72
|
+
{
|
|
73
|
+
// Warmer ping (scheduled keep-warm rule): keep the container alive, return fast.
|
|
74
|
+
if (($event['warmer'] ?? false) === true) {
|
|
75
|
+
return ['statusCode' => 200, 'headers' => ['content-type' => 'text/plain'], 'body' => 'warm', 'isBase64Encoded' => false];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
$http = $event['requestContext']['http'] ?? [];
|
|
79
|
+
$method = $http['method'] ?? 'GET';
|
|
80
|
+
$rawPath = $event['rawPath'] ?? '/';
|
|
81
|
+
$rawQuery = $event['rawQueryString'] ?? '';
|
|
82
|
+
$headers = $event['headers'] ?? [];
|
|
83
|
+
$cookies = $event['cookies'] ?? [];
|
|
84
|
+
|
|
85
|
+
// Maintenance mode: 503 unless the bypass secret is presented.
|
|
86
|
+
if ($maintenance) {
|
|
87
|
+
$bypass = $headers['x-maintenance-bypass'] ?? '';
|
|
88
|
+
if ($bypassSecret === '' || $bypass !== $bypassSecret) {
|
|
89
|
+
return [
|
|
90
|
+
'statusCode' => 503,
|
|
91
|
+
'headers' => ['content-type' => 'text/plain', 'retry-after' => '120'],
|
|
92
|
+
'body' => 'Service temporarily unavailable (maintenance mode)',
|
|
93
|
+
'isBase64Encoded' => false,
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$body = $event['body'] ?? '';
|
|
99
|
+
if (($event['isBase64Encoded'] ?? false) === true) {
|
|
100
|
+
$body = base64_decode($body);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
$params = [
|
|
104
|
+
'GATEWAY_INTERFACE' => 'CGI/1.1',
|
|
105
|
+
'REQUEST_METHOD' => $method,
|
|
106
|
+
'SCRIPT_FILENAME' => $docRoot . '/index.php',
|
|
107
|
+
'SCRIPT_NAME' => '/index.php',
|
|
108
|
+
'PATH_INFO' => $rawPath,
|
|
109
|
+
'REQUEST_URI' => $rawQuery !== '' ? $rawPath . '?' . $rawQuery : $rawPath,
|
|
110
|
+
'QUERY_STRING' => $rawQuery,
|
|
111
|
+
'DOCUMENT_ROOT' => $docRoot,
|
|
112
|
+
'SERVER_PROTOCOL' => $http['protocol'] ?? 'HTTP/1.1',
|
|
113
|
+
'SERVER_SOFTWARE' => 'ts-cloud-lambda',
|
|
114
|
+
'REMOTE_ADDR' => $http['sourceIp'] ?? '127.0.0.1',
|
|
115
|
+
'SERVER_NAME' => $event['requestContext']['domainName'] ?? 'localhost',
|
|
116
|
+
'SERVER_PORT' => '443',
|
|
117
|
+
'HTTPS' => 'on',
|
|
118
|
+
'CONTENT_LENGTH' => (string) strlen($body),
|
|
119
|
+
];
|
|
120
|
+
if (isset($headers['content-type'])) {
|
|
121
|
+
$params['CONTENT_TYPE'] = $headers['content-type'];
|
|
122
|
+
}
|
|
123
|
+
if (!empty($cookies)) {
|
|
124
|
+
$headers['cookie'] = implode('; ', $cookies);
|
|
125
|
+
}
|
|
126
|
+
foreach ($headers as $name => $value) {
|
|
127
|
+
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
|
128
|
+
$params[$key] = $value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
$result = $fpm->request($params, $body);
|
|
132
|
+
return parseFpmResponse($result['stdout']);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseFpmResponse(string $raw): array
|
|
136
|
+
{
|
|
137
|
+
// Split headers from body.
|
|
138
|
+
$pos = strpos($raw, "\r\n\r\n");
|
|
139
|
+
if ($pos === false) {
|
|
140
|
+
$pos = strpos($raw, "\n\n");
|
|
141
|
+
$headerBlock = $pos === false ? '' : substr($raw, 0, $pos);
|
|
142
|
+
$body = $pos === false ? $raw : substr($raw, $pos + 2);
|
|
143
|
+
} else {
|
|
144
|
+
$headerBlock = substr($raw, 0, $pos);
|
|
145
|
+
$body = substr($raw, $pos + 4);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$statusCode = 200;
|
|
149
|
+
$headers = [];
|
|
150
|
+
$cookies = [];
|
|
151
|
+
foreach (preg_split('/\r\n|\n/', $headerBlock) as $line) {
|
|
152
|
+
if (trim($line) === '') {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
$parts = explode(':', $line, 2);
|
|
156
|
+
if (count($parts) !== 2) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
$name = strtolower(trim($parts[0]));
|
|
160
|
+
$value = trim($parts[1]);
|
|
161
|
+
if ($name === 'status') {
|
|
162
|
+
$statusCode = (int) substr($value, 0, 3);
|
|
163
|
+
} elseif ($name === 'set-cookie') {
|
|
164
|
+
$cookies[] = $value;
|
|
165
|
+
} else {
|
|
166
|
+
$headers[$name] = $value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
$contentType = $headers['content-type'] ?? 'text/html';
|
|
171
|
+
$isText = (bool) preg_match('#^(text/|application/(json|xml|javascript|x-www-form-urlencoded)|image/svg)#i', $contentType);
|
|
172
|
+
|
|
173
|
+
$response = [
|
|
174
|
+
'statusCode' => $statusCode,
|
|
175
|
+
'headers' => $headers,
|
|
176
|
+
'isBase64Encoded' => !$isText,
|
|
177
|
+
'body' => $isText ? $body : base64_encode($body),
|
|
178
|
+
];
|
|
179
|
+
if (!empty($cookies)) {
|
|
180
|
+
$response['cookies'] = $cookies;
|
|
181
|
+
}
|
|
182
|
+
return $response;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function postResponse(string $api, string $requestId, array $response): void
|
|
186
|
+
{
|
|
187
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/response");
|
|
188
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
189
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
190
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($response));
|
|
191
|
+
curl_exec($ch);
|
|
192
|
+
curl_close($ch);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function postError(string $api, string $requestId, \Throwable $e): void
|
|
196
|
+
{
|
|
197
|
+
$ch = curl_init("http://{$api}/2018-06-01/runtime/invocation/{$requestId}/error");
|
|
198
|
+
curl_setopt($ch, CURLOPT_POST, true);
|
|
199
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
200
|
+
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
201
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
202
|
+
'errorType' => get_class($e),
|
|
203
|
+
'errorMessage' => $e->getMessage(),
|
|
204
|
+
]));
|
|
205
|
+
curl_exec($ch);
|
|
206
|
+
curl_close($ch);
|
|
207
|
+
}
|
package/dist/runtime.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-cloud JS Lambda custom-runtime loop (provided.al2023).
|
|
3
|
+
*
|
|
4
|
+
* Shared by both the Node and Bun custom runtimes — it is plain ESM that runs
|
|
5
|
+
* under either `node /opt/runtime.mjs` or `bun /opt/runtime.mjs`. It implements
|
|
6
|
+
* the AWS Lambda Runtime API event loop and delegates each invocation to the
|
|
7
|
+
* handler exported by the deployment artifact (the ts-cloud serverless adapter
|
|
8
|
+
* already bundled `http`/`queue`/`cli` into `index.mjs`).
|
|
9
|
+
*
|
|
10
|
+
* The function's configured Handler (e.g. `index.http`) arrives as `_HANDLER`,
|
|
11
|
+
* so the same artifact + layer serve all three functions; only `_HANDLER`
|
|
12
|
+
* differs per function.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const RUNTIME_API = process.env.AWS_LAMBDA_RUNTIME_API
|
|
16
|
+
const BASE = `http://${RUNTIME_API}/2018-06-01/runtime`
|
|
17
|
+
const TASK_ROOT = process.env.LAMBDA_TASK_ROOT ?? '/var/task'
|
|
18
|
+
const HANDLER = process.env._HANDLER ?? 'index.http'
|
|
19
|
+
|
|
20
|
+
async function resolveHandler() {
|
|
21
|
+
const lastDot = HANDLER.lastIndexOf('.')
|
|
22
|
+
const file = lastDot === -1 ? HANDLER : HANDLER.slice(0, lastDot)
|
|
23
|
+
const exportName = lastDot === -1 ? 'http' : HANDLER.slice(lastDot + 1)
|
|
24
|
+
|
|
25
|
+
let mod
|
|
26
|
+
for (const ext of ['.mjs', '.js', '.cjs', '']) {
|
|
27
|
+
try {
|
|
28
|
+
mod = await import(`${TASK_ROOT}/${file}${ext}`)
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// try the next extension
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!mod)
|
|
36
|
+
throw new Error(`Cannot load handler module "${file}" from ${TASK_ROOT}`)
|
|
37
|
+
|
|
38
|
+
const fn = mod[exportName] ?? mod.default?.[exportName] ?? mod.default
|
|
39
|
+
if (typeof fn !== 'function')
|
|
40
|
+
throw new TypeError(`Handler "${HANDLER}" did not resolve to a function`)
|
|
41
|
+
return fn
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function postInitError(err) {
|
|
45
|
+
await fetch(`${BASE}/init/error`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ errorType: err?.name ?? 'InitError', errorMessage: String(err?.message ?? err) }),
|
|
49
|
+
}).catch(() => {})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
let handler
|
|
54
|
+
try {
|
|
55
|
+
handler = await resolveHandler()
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
await postInitError(err)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The Runtime API event loop: long-poll for the next invocation, run the
|
|
64
|
+
// handler, post the response (or error), repeat.
|
|
65
|
+
for (;;) {
|
|
66
|
+
const next = await fetch(`${BASE}/invocation/next`)
|
|
67
|
+
const requestId = next.headers.get('lambda-runtime-aws-request-id')
|
|
68
|
+
if (!requestId) continue
|
|
69
|
+
|
|
70
|
+
let event
|
|
71
|
+
try {
|
|
72
|
+
event = await next.json()
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
event = {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const result = await handler(event)
|
|
80
|
+
await fetch(`${BASE}/invocation/${requestId}/response`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
body: result === undefined ? '' : JSON.stringify(result),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
await fetch(`${BASE}/invocation/${requestId}/error`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ errorType: err?.name ?? 'Error', errorMessage: String(err?.message ?? err) }),
|
|
90
|
+
}).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main()
|
package/dist/types.d.ts
CHANGED
|
@@ -1632,8 +1632,10 @@ export interface ServerlessAppConfig {
|
|
|
1632
1632
|
* @default 'zip'
|
|
1633
1633
|
*/
|
|
1634
1634
|
packaging?: 'zip' | 'image';
|
|
1635
|
-
/** Attach the functions to a VPC (required for ElastiCache / private RDS). */
|
|
1635
|
+
/** Attach the functions to a VPC (required for ElastiCache / private RDS / EFS). */
|
|
1636
1636
|
vpc?: {
|
|
1637
|
+
/** VPC id — required when ts-cloud provisions a managed security group for data services. */
|
|
1638
|
+
id?: string;
|
|
1637
1639
|
subnets?: string[];
|
|
1638
1640
|
securityGroups?: string[];
|
|
1639
1641
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ts-cloud/core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core CloudFormation generation library for ts-cloud",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.com>",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ts-cloud/aws-types": "0.5.
|
|
34
|
+
"@ts-cloud/aws-types": "0.5.4"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.9.3"
|