@ts-cloud/core 0.5.1 → 0.5.3

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.
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+ # ts-cloud Bun custom-runtime entrypoint (provided.al2023).
3
+ # Puts the bundled Bun binary on PATH and runs the shared Runtime API loop.
4
+ export PATH="/opt/bin:$PATH"
5
+ exec /opt/bin/bun /opt/runtime.mjs
package/dist/index.js CHANGED
@@ -45522,11 +45522,16 @@ function composeServerlessAppTemplate(opts) {
45522
45522
  ...hasQueue ? { TSCLOUD_QUEUE: queueNames[0] } : {},
45523
45523
  ...app.env ?? {}
45524
45524
  });
45525
+ const efsEnabled = Boolean(app.efs);
45526
+ const efsOpts = typeof app.efs === "object" ? app.efs : {};
45527
+ const efsMountPath = efsOpts.mountPath ?? "/mnt/local";
45528
+ const efsProvision = efsEnabled && !efsOpts.accessPointArn;
45529
+ const efsAccessPoint = efsOpts.accessPointArn ?? (efsProvision ? Fn2.getAtt("EfsAccessPoint", "Arn") : undefined);
45525
45530
  const subnets = app.vpc?.subnets ?? [];
45526
45531
  const hasVpc = subnets.length > 0;
45527
- const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy);
45532
+ const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy) || efsEnabled;
45528
45533
  if (needsDataVpc && !hasVpc) {
45529
- throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy require app.vpc.subnets (private subnets) to be set.");
45534
+ throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy / efs require app.vpc.subnets (private subnets) to be set.");
45530
45535
  }
45531
45536
  const vpcConfig = hasVpc ? {
45532
45537
  VpcConfig: {
@@ -45537,6 +45542,15 @@ function composeServerlessAppTemplate(opts) {
45537
45542
  ]
45538
45543
  }
45539
45544
  } : {};
45545
+ const efsDependsOn = efsProvision ? subnets.map((_, i) => `EfsMountTarget${i}`) : [];
45546
+ const efsConfig = efsEnabled ? { FileSystemConfigs: [{ Arn: efsAccessPoint, LocalMountPath: efsMountPath }] } : {};
45547
+ if (efsEnabled) {
45548
+ inlinePolicies[0].PolicyDocument.Statement.push({
45549
+ Effect: "Allow",
45550
+ Action: ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite", "elasticfilesystem:ClientRootAccess", "elasticfilesystem:DescribeMountTargets"],
45551
+ Resource: "*"
45552
+ });
45553
+ }
45540
45554
  function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency, tmp = tmpStorage) {
45541
45555
  resources[`${logicalId}LogGroup`] = {
45542
45556
  Type: "AWS::Logs::LogGroup",
@@ -45554,7 +45568,7 @@ function composeServerlessAppTemplate(opts) {
45554
45568
  };
45555
45569
  resources[logicalId] = {
45556
45570
  Type: "AWS::Lambda::Function",
45557
- DependsOn: [`${logicalId}LogGroup`],
45571
+ DependsOn: [`${logicalId}LogGroup`, ...efsDependsOn],
45558
45572
  Properties: {
45559
45573
  FunctionName: name,
45560
45574
  Architectures: [architecture],
@@ -45565,7 +45579,8 @@ function composeServerlessAppTemplate(opts) {
45565
45579
  EphemeralStorage: { Size: tmp },
45566
45580
  ...codeProps,
45567
45581
  ...reservedConcurrency !== undefined ? { ReservedConcurrentExecutions: reservedConcurrency } : {},
45568
- ...vpcConfig
45582
+ ...vpcConfig,
45583
+ ...efsConfig
45569
45584
  }
45570
45585
  };
45571
45586
  }
@@ -45814,10 +45829,37 @@ function composeServerlessAppTemplate(opts) {
45814
45829
  DomainName: Fn2.getAtt("AssetsBucket", "RegionalDomainName"),
45815
45830
  OriginAccessControlId: Fn2.ref("AssetsOAC"),
45816
45831
  S3OriginConfig: { OriginAccessIdentity: "" }
45817
- }]
45832
+ }],
45833
+ ...app.assetDomain ? {
45834
+ Aliases: [app.assetDomain],
45835
+ ViewerCertificate: {
45836
+ AcmCertificateArn: app.assetCertificateArn,
45837
+ SslSupportMethod: "sni-only",
45838
+ MinimumProtocolVersion: "TLSv1.2_2021"
45839
+ }
45840
+ } : {}
45818
45841
  }
45819
45842
  }
45820
45843
  };
45844
+ if (app.assetDomain) {
45845
+ if (!app.assetCertificateArn)
45846
+ throw new Error("serverless app: `assetDomain` requires `assetCertificateArn` (a us-east-1 ACM cert — CloudFront only accepts certs from us-east-1).");
45847
+ if (app.hostedZoneId) {
45848
+ resources.AssetsDomainRecord = {
45849
+ Type: "AWS::Route53::RecordSet",
45850
+ Properties: {
45851
+ HostedZoneId: app.hostedZoneId,
45852
+ Name: app.assetDomain,
45853
+ Type: "A",
45854
+ AliasTarget: {
45855
+ DNSName: Fn2.getAtt("AssetsDistribution", "DomainName"),
45856
+ HostedZoneId: "Z2FDTNDATAQYW2"
45857
+ }
45858
+ }
45859
+ };
45860
+ }
45861
+ outputs.AssetDomain = { Description: "Custom asset CDN host", Value: app.assetDomain };
45862
+ }
45821
45863
  resources.AssetsBucketPolicy = {
45822
45864
  Type: "AWS::S3::BucketPolicy",
45823
45865
  Properties: {
@@ -45896,6 +45938,37 @@ function composeServerlessAppTemplate(opts) {
45896
45938
  }
45897
45939
  };
45898
45940
  }
45941
+ if (efsProvision) {
45942
+ resources.EfsFileSystem = {
45943
+ Type: "AWS::EFS::FileSystem",
45944
+ Properties: {
45945
+ Encrypted: true,
45946
+ FileSystemTags: [{ Key: "Name", Value: `${slug}-${environment}-efs` }]
45947
+ }
45948
+ };
45949
+ subnets.forEach((subnetId, i) => {
45950
+ resources[`EfsMountTarget${i}`] = {
45951
+ Type: "AWS::EFS::MountTarget",
45952
+ Properties: {
45953
+ FileSystemId: Fn2.ref("EfsFileSystem"),
45954
+ SubnetId: subnetId,
45955
+ SecurityGroups: [Fn2.getAtt("DataSecurityGroup", "GroupId")]
45956
+ }
45957
+ };
45958
+ });
45959
+ resources.EfsAccessPoint = {
45960
+ Type: "AWS::EFS::AccessPoint",
45961
+ Properties: {
45962
+ FileSystemId: Fn2.ref("EfsFileSystem"),
45963
+ PosixUser: { Uid: 1001, Gid: 1001 },
45964
+ RootDirectory: {
45965
+ Path: "/lambda",
45966
+ CreationInfo: { OwnerUid: 1001, OwnerGid: 1001, Permissions: "0755" }
45967
+ }
45968
+ }
45969
+ };
45970
+ outputs.EfsFileSystemId = { Description: "EFS file system id", Value: Fn2.ref("EfsFileSystem") };
45971
+ }
45899
45972
  if (app.cache?.driver === "elasticache") {
45900
45973
  resources.CacheSubnetGroup = {
45901
45974
  Type: "AWS::ElastiCache::SubnetGroup",
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+ # ts-cloud Node custom-runtime entrypoint (provided.al2023).
3
+ # Puts the bundled Node binary on PATH and runs the shared Runtime API loop.
4
+ export PATH="/opt/bin:$PATH"
5
+ exec /opt/bin/node /opt/runtime.mjs
@@ -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
+ }