@astrale-os/sdk 0.4.13 → 0.4.15
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/domain/extend-functions.d.ts.map +1 -1
- package/dist/domain/extend-functions.js +2 -1
- package/dist/domain/extend-functions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/linter/project.d.ts +6 -0
- package/dist/linter/project.d.ts.map +1 -1
- package/dist/linter/project.js +69 -7
- package/dist/linter/project.js.map +1 -1
- package/dist/server/auxiliary-routes.d.ts +5 -1
- package/dist/server/auxiliary-routes.d.ts.map +1 -1
- package/dist/server/auxiliary-routes.js +15 -6
- package/dist/server/auxiliary-routes.js.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/service-entry.d.ts +32 -0
- package/dist/server/service-entry.d.ts.map +1 -0
- package/dist/server/service-entry.js +150 -0
- package/dist/server/service-entry.js.map +1 -0
- package/dist/server/worker-entry.d.ts +7 -3
- package/dist/server/worker-entry.d.ts.map +1 -1
- package/dist/server/worker-entry.js +7 -1
- package/dist/server/worker-entry.js.map +1 -1
- package/dist/service/functions.d.ts +148 -0
- package/dist/service/functions.d.ts.map +1 -0
- package/dist/service/functions.js +115 -0
- package/dist/service/functions.js.map +1 -0
- package/dist/service/index.d.ts +3 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +2 -0
- package/dist/service/index.js.map +1 -0
- package/package.json +1 -1
- package/src/domain/extend-functions.ts +5 -1
- package/src/index.ts +18 -0
- package/src/linter/docs/RULES.md +1 -1
- package/src/linter/project.ts +88 -6
- package/src/server/auxiliary-routes.ts +34 -7
- package/src/server/index.ts +10 -2
- package/src/server/service-entry.ts +209 -0
- package/src/server/worker-entry.ts +18 -5
- package/src/service/functions.ts +164 -0
- package/src/service/index.ts +18 -0
package/src/linter/project.ts
CHANGED
|
@@ -23,12 +23,23 @@ export type DomainProject = {
|
|
|
23
23
|
root: string
|
|
24
24
|
files: readonly SourceFile[]
|
|
25
25
|
filesByPath: ReadonlyMap<string, SourceFile>
|
|
26
|
+
packageScopes: readonly PackageScope[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PackageScope = {
|
|
30
|
+
directory: string
|
|
31
|
+
imports: ReadonlyMap<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type PackageJson = {
|
|
35
|
+
imports?: Record<string, unknown>
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export async function discoverProject(root: string): Promise<DomainProject> {
|
|
29
39
|
const projectRoot = await realpath(resolve(root))
|
|
30
40
|
const paths: string[] = []
|
|
31
|
-
|
|
41
|
+
const manifests: string[] = []
|
|
42
|
+
await collectProjectPaths(projectRoot, paths, manifests)
|
|
32
43
|
paths.sort()
|
|
33
44
|
|
|
34
45
|
const files = await Promise.all(
|
|
@@ -41,26 +52,55 @@ export async function discoverProject(root: string): Promise<DomainProject> {
|
|
|
41
52
|
root: projectRoot,
|
|
42
53
|
files,
|
|
43
54
|
filesByPath: new Map(files.map((file) => [file.path, file])),
|
|
55
|
+
packageScopes: await loadPackageScopes(manifests),
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
async function
|
|
59
|
+
async function collectProjectPaths(
|
|
60
|
+
directory: string,
|
|
61
|
+
sources: string[],
|
|
62
|
+
manifests: string[],
|
|
63
|
+
): Promise<void> {
|
|
48
64
|
const entries = await readdir(directory, { withFileTypes: true })
|
|
49
65
|
await Promise.all(
|
|
50
66
|
entries.map(async (entry) => {
|
|
51
67
|
if (entry.isSymbolicLink()) return
|
|
52
68
|
const path = join(directory, entry.name)
|
|
53
69
|
if (entry.isDirectory()) {
|
|
54
|
-
if (!ignoredDirectories.has(entry.name))
|
|
70
|
+
if (!ignoredDirectories.has(entry.name)) {
|
|
71
|
+
await collectProjectPaths(path, sources, manifests)
|
|
72
|
+
}
|
|
55
73
|
return
|
|
56
74
|
}
|
|
75
|
+
if (entry.isFile() && entry.name === 'package.json') manifests.push(path)
|
|
57
76
|
if (!entry.isFile() || !sourceExtensions.has(extname(entry.name))) return
|
|
58
77
|
if (entry.name.endsWith('.d.ts') || entry.name.includes('.gen.')) return
|
|
59
|
-
|
|
78
|
+
sources.push(path)
|
|
60
79
|
}),
|
|
61
80
|
)
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
async function loadPackageScopes(manifests: readonly string[]): Promise<PackageScope[]> {
|
|
84
|
+
const scopes = await Promise.all(
|
|
85
|
+
manifests.map(async (manifest): Promise<PackageScope | undefined> => {
|
|
86
|
+
let packageJson: PackageJson
|
|
87
|
+
try {
|
|
88
|
+
packageJson = JSON.parse(await readFile(manifest, 'utf8')) as PackageJson
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
const imports = new Map<string, string>()
|
|
93
|
+
for (const [specifier, target] of Object.entries(packageJson.imports ?? {})) {
|
|
94
|
+
if (specifier.startsWith('#') && typeof target === 'string') imports.set(specifier, target)
|
|
95
|
+
}
|
|
96
|
+
return { directory: dirname(manifest), imports }
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
return scopes
|
|
100
|
+
.filter((scope): scope is PackageScope => scope !== undefined)
|
|
101
|
+
.sort((a, b) => b.directory.length - a.directory.length)
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
export function normalizePath(path: string): string {
|
|
65
105
|
return sep === '/' ? path : path.split(sep).join('/')
|
|
66
106
|
}
|
|
@@ -84,8 +124,10 @@ export function resolveProjectImport(
|
|
|
84
124
|
importer: SourceFile,
|
|
85
125
|
specifier: string,
|
|
86
126
|
): SourceFile | undefined {
|
|
87
|
-
|
|
88
|
-
|
|
127
|
+
const unresolved = specifier.startsWith('.')
|
|
128
|
+
? resolve(dirname(importer.path), specifier)
|
|
129
|
+
: resolvePackageImport(project, importer, specifier)
|
|
130
|
+
if (!unresolved) return undefined
|
|
89
131
|
for (const candidate of importCandidates(unresolved)) {
|
|
90
132
|
const file = project.filesByPath.get(candidate)
|
|
91
133
|
if (file) return file
|
|
@@ -93,6 +135,46 @@ export function resolveProjectImport(
|
|
|
93
135
|
return undefined
|
|
94
136
|
}
|
|
95
137
|
|
|
138
|
+
function resolvePackageImport(
|
|
139
|
+
project: DomainProject,
|
|
140
|
+
importer: SourceFile,
|
|
141
|
+
specifier: string,
|
|
142
|
+
): string | undefined {
|
|
143
|
+
if (!specifier.startsWith('#')) return undefined
|
|
144
|
+
const scope = project.packageScopes.find(({ directory }) => isWithin(directory, importer.path))
|
|
145
|
+
if (!scope) return undefined
|
|
146
|
+
const target = scope.imports.get(specifier) ?? matchingImportTarget(scope.imports, specifier)
|
|
147
|
+
if (!target?.startsWith('./')) return undefined
|
|
148
|
+
const path = resolve(scope.directory, target)
|
|
149
|
+
return isWithin(scope.directory, path) ? path : undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function matchingImportTarget(
|
|
153
|
+
imports: ReadonlyMap<string, string>,
|
|
154
|
+
specifier: string,
|
|
155
|
+
): string | undefined {
|
|
156
|
+
let best: { specificity: number; target: string } | undefined
|
|
157
|
+
for (const [pattern, target] of imports) {
|
|
158
|
+
const star = pattern.indexOf('*')
|
|
159
|
+
if (star < 0 || star !== pattern.lastIndexOf('*')) continue
|
|
160
|
+
const prefix = pattern.slice(0, star)
|
|
161
|
+
const suffix = pattern.slice(star + 1)
|
|
162
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue
|
|
163
|
+
const matchEnd = suffix.length === 0 ? specifier.length : specifier.length - suffix.length
|
|
164
|
+
const match = specifier.slice(prefix.length, matchEnd)
|
|
165
|
+
const specificity = prefix.length + suffix.length
|
|
166
|
+
if (!best || specificity > best.specificity) {
|
|
167
|
+
best = { specificity, target: target.replace('*', match) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best?.target
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isWithin(directory: string, path: string): boolean {
|
|
174
|
+
const child = relative(directory, path)
|
|
175
|
+
return child === '' || (!child.startsWith('..') && !isAbsolute(child))
|
|
176
|
+
}
|
|
177
|
+
|
|
96
178
|
function importCandidates(path: string): string[] {
|
|
97
179
|
const extension = extname(path)
|
|
98
180
|
const candidates = [path]
|
|
@@ -70,7 +70,13 @@ export type AuxiliaryRoutesConfig<TDeps> = {
|
|
|
70
70
|
* one per RemoteFunction. Build via `buildAuxIdentityMap(compiled, key, issuer)`
|
|
71
71
|
* from `sdk/src/dispatch/identity.ts`.
|
|
72
72
|
*/
|
|
73
|
-
identities
|
|
73
|
+
identities?: AuxIdentityMap
|
|
74
|
+
/** Resolve an identity lazily. Service-hosted Functions use this to read the
|
|
75
|
+
* Function node registered during deploy and reuse the Service signing key. */
|
|
76
|
+
resolveIdentity?: (
|
|
77
|
+
kind: 'view' | 'remoteFunction',
|
|
78
|
+
slug: string,
|
|
79
|
+
) => RemoteIdentityConfig | Promise<RemoteIdentityConfig>
|
|
74
80
|
/**
|
|
75
81
|
* CORS policy applied to every mounted route: per-route `app.options(...)`
|
|
76
82
|
* preflight and `Access-Control-Allow-*` headers on success + error
|
|
@@ -90,6 +96,7 @@ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>
|
|
|
90
96
|
remoteFunctionBindings,
|
|
91
97
|
deps,
|
|
92
98
|
identities,
|
|
99
|
+
resolveIdentity,
|
|
93
100
|
cors,
|
|
94
101
|
} = config
|
|
95
102
|
|
|
@@ -100,7 +107,7 @@ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>
|
|
|
100
107
|
for (const [slug, def] of Object.entries(views)) {
|
|
101
108
|
const binding = viewBindings[slug]
|
|
102
109
|
if (!binding || !def.render) continue
|
|
103
|
-
const identity =
|
|
110
|
+
const identity = identityResolver('view', slug, identities?.views[slug], resolveIdentity)
|
|
104
111
|
mountEntry({
|
|
105
112
|
app,
|
|
106
113
|
binding,
|
|
@@ -112,7 +119,7 @@ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>
|
|
|
112
119
|
// Views are transport-only (iframe HTML/redirect). SERVER-rendered
|
|
113
120
|
// views that read the graph use `ctx.fn.kernel()`: the view's own
|
|
114
121
|
// identity, with narrow grants.
|
|
115
|
-
run: async ({ c, params, auth, kernel }) => {
|
|
122
|
+
run: async ({ c, params, auth, kernel, identity }) => {
|
|
116
123
|
const inboundIss = auth?.credential?.verified?.iss as string | undefined
|
|
117
124
|
const kernelUrl = kernel?.default ?? inboundIss
|
|
118
125
|
const ctx = {
|
|
@@ -137,7 +144,12 @@ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>
|
|
|
137
144
|
for (const [slug, def] of Object.entries(remoteFunctions)) {
|
|
138
145
|
const binding = remoteFunctionBindings[slug]
|
|
139
146
|
if (!binding) continue
|
|
140
|
-
const identity =
|
|
147
|
+
const identity = identityResolver(
|
|
148
|
+
'remoteFunction',
|
|
149
|
+
slug,
|
|
150
|
+
identities?.remoteFunctions[slug],
|
|
151
|
+
resolveIdentity,
|
|
152
|
+
)
|
|
141
153
|
mountEntry({
|
|
142
154
|
app,
|
|
143
155
|
binding,
|
|
@@ -146,7 +158,7 @@ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>
|
|
|
146
158
|
auth: def.auth,
|
|
147
159
|
identity,
|
|
148
160
|
corsHeaders,
|
|
149
|
-
run: async ({ c, auth, kernel }) => {
|
|
161
|
+
run: async ({ c, auth, kernel, identity }) => {
|
|
150
162
|
const input = await readRemoteFunctionInput(c)
|
|
151
163
|
const validation = validateParams(def.inputSchema, input.params)
|
|
152
164
|
if (!validation.ok) {
|
|
@@ -227,11 +239,24 @@ function requireAuxIdentity(
|
|
|
227
239
|
)
|
|
228
240
|
}
|
|
229
241
|
|
|
242
|
+
function identityResolver(
|
|
243
|
+
kind: 'view' | 'remoteFunction',
|
|
244
|
+
slug: string,
|
|
245
|
+
identity: RemoteIdentityConfig | undefined,
|
|
246
|
+
resolveIdentity: AuxiliaryRoutesConfig<unknown>['resolveIdentity'],
|
|
247
|
+
): () => Promise<RemoteIdentityConfig> {
|
|
248
|
+
if (resolveIdentity) return async () => resolveIdentity(kind, slug)
|
|
249
|
+
const label = kind === 'view' ? 'view' : 'remote function'
|
|
250
|
+
const required = requireAuxIdentity(label, slug, identity)
|
|
251
|
+
return async () => required
|
|
252
|
+
}
|
|
253
|
+
|
|
230
254
|
type RunArgs = {
|
|
231
255
|
c: Context
|
|
232
256
|
params: Record<string, string>
|
|
233
257
|
auth: AuthContext | null
|
|
234
258
|
kernel: BoundClientSessionView<FnMap> | null
|
|
259
|
+
identity: RemoteIdentityConfig
|
|
235
260
|
}
|
|
236
261
|
|
|
237
262
|
type MountEntryArgs = {
|
|
@@ -241,7 +266,7 @@ type MountEntryArgs = {
|
|
|
241
266
|
defaultMethod: 'GET' | 'POST'
|
|
242
267
|
run: (args: RunArgs) => Promise<Response>
|
|
243
268
|
auth?: AuthPolicy
|
|
244
|
-
identity: RemoteIdentityConfig
|
|
269
|
+
identity: () => Promise<RemoteIdentityConfig>
|
|
245
270
|
corsHeaders: Record<string, string>
|
|
246
271
|
}
|
|
247
272
|
|
|
@@ -296,10 +321,11 @@ function mountEntry(args: MountEntryArgs): void {
|
|
|
296
321
|
if (value !== undefined) pathParams[name] = decodeURIComponent(value)
|
|
297
322
|
}
|
|
298
323
|
|
|
324
|
+
const resolvedIdentity = await identity()
|
|
299
325
|
const { auth: resolvedAuth, kernel } = await resolveInboundAuth(
|
|
300
326
|
stripBearerPrefix(c.req.header('authorization') ?? ''),
|
|
301
327
|
auth,
|
|
302
|
-
|
|
328
|
+
resolvedIdentity,
|
|
303
329
|
)
|
|
304
330
|
|
|
305
331
|
const response = await run({
|
|
@@ -307,6 +333,7 @@ function mountEntry(args: MountEntryArgs): void {
|
|
|
307
333
|
params: { ...hostParams, ...pathParams },
|
|
308
334
|
auth: resolvedAuth,
|
|
309
335
|
kernel,
|
|
336
|
+
identity: resolvedIdentity,
|
|
310
337
|
})
|
|
311
338
|
return applyCorsToResponse(response, corsHeaders)
|
|
312
339
|
} catch (err) {
|
package/src/server/index.ts
CHANGED
|
@@ -4,8 +4,16 @@ export type { RemoteServer, RemoteServerHandle } from './handle.js'
|
|
|
4
4
|
export { derivePublicJwk } from './jwks.js'
|
|
5
5
|
export { requireEnv } from './require-env.js'
|
|
6
6
|
export { canonicalizeServingUrl } from './serving-url.js'
|
|
7
|
-
export { assets, createWorkerEntry } from './worker-entry.js'
|
|
8
|
-
export type {
|
|
7
|
+
export { assets, createAppWorkerEntry, createWorkerEntry } from './worker-entry.js'
|
|
8
|
+
export type {
|
|
9
|
+
AppWorkerEntryConfig,
|
|
10
|
+
Fetcher,
|
|
11
|
+
WorkerApp,
|
|
12
|
+
WorkerEntry,
|
|
13
|
+
WorkerEntryConfig,
|
|
14
|
+
} from './worker-entry.js'
|
|
9
15
|
export { domainWorkerEntry } from './domain-entry.js'
|
|
10
16
|
export type { DomainWorkerEntryConfig } from './domain-entry.js'
|
|
17
|
+
export { serviceWorkerEntry } from './service-entry.js'
|
|
18
|
+
export type { ServiceWorkerEntryConfig, ServiceWorkerIdentityEnv } from './service-entry.js'
|
|
11
19
|
export { startNodeServer } from './start.js'
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { FunctionBinding } from '@astrale-os/kernel-api/routed'
|
|
2
|
+
import type { Node } from '@astrale-os/kernel-core'
|
|
3
|
+
import type { CorsConfig } from '@astrale-os/kernel-server'
|
|
4
|
+
|
|
5
|
+
import { K } from '@astrale-os/kernel-core'
|
|
6
|
+
import { Hono } from 'hono'
|
|
7
|
+
|
|
8
|
+
import type { RemoteIdentityConfig } from '../auth/identity.js'
|
|
9
|
+
import type { AnyRemoteFunctionDef } from '../define/remote-function.js'
|
|
10
|
+
import type { Fetcher, WorkerEntry } from './worker-entry.js'
|
|
11
|
+
|
|
12
|
+
import { makeFunctionContext } from '../auth/function-context.js'
|
|
13
|
+
import { buildFunctionSchemas, DEFAULT_FUNCTIONS_FOLDER } from '../domain/extend-functions.js'
|
|
14
|
+
import {
|
|
15
|
+
SERVICE_FUNCTIONS_PATH,
|
|
16
|
+
ServiceFunctionDiscoveryRequestSchema,
|
|
17
|
+
signServiceFunctionManifest,
|
|
18
|
+
toServiceFunctionManifestEntries,
|
|
19
|
+
} from '../service/functions.js'
|
|
20
|
+
import { mountAuxiliaryRoutes } from './auxiliary-routes.js'
|
|
21
|
+
import { createAppWorkerEntry } from './worker-entry.js'
|
|
22
|
+
|
|
23
|
+
export interface ServiceWorkerIdentityEnv {
|
|
24
|
+
IDENTITY_ISS?: string
|
|
25
|
+
IDENTITY_SUB?: string
|
|
26
|
+
IDENTITY_PRIVATE_KEY?: string
|
|
27
|
+
ASTRALE_KERNEL_AUDIENCE?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ServiceWorkerEntryConfig<TEnv extends ServiceWorkerIdentityEnv> {
|
|
31
|
+
functions?: Record<string, AnyRemoteFunctionDef>
|
|
32
|
+
functionsFolder?: string
|
|
33
|
+
cors?: CorsConfig
|
|
34
|
+
resolveUrl?: (env: TEnv, requestOrigin: string) => string
|
|
35
|
+
selfBinding?: (env: TEnv) => Fetcher | null | undefined
|
|
36
|
+
routeSubrequest?: (url: URL, env: TEnv) => Fetcher | null | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A Service worker with zero or more first-class kernel Functions.
|
|
40
|
+
*
|
|
41
|
+
* The function map is the only declaration: it drives both HTTP routes and the
|
|
42
|
+
* signed deploy-time manifest. Function identities are read lazily from the
|
|
43
|
+
* graph after Services has reconciled them, while every Function reuses the
|
|
44
|
+
* Service's one private signing key. */
|
|
45
|
+
export function serviceWorkerEntry<TEnv extends ServiceWorkerIdentityEnv>(
|
|
46
|
+
config: ServiceWorkerEntryConfig<TEnv>,
|
|
47
|
+
): WorkerEntry<TEnv> {
|
|
48
|
+
const functions = config.functions ?? {}
|
|
49
|
+
const functionsFolder = config.functionsFolder ?? DEFAULT_FUNCTIONS_FOLDER
|
|
50
|
+
|
|
51
|
+
return createAppWorkerEntry<TEnv>({
|
|
52
|
+
buildApp: (url, env) => {
|
|
53
|
+
const app = new Hono()
|
|
54
|
+
const serviceIdentity = identityFromEnv(env)
|
|
55
|
+
const { schemas, bindings } = buildFunctionSchemas(functions, url, functionsFolder)
|
|
56
|
+
assertLocalBindings(url, bindings)
|
|
57
|
+
const resolveFunctionIdentity = createFunctionIdentityResolver(env, serviceIdentity)
|
|
58
|
+
|
|
59
|
+
app.get('/', (c) => c.json({ ok: true, kind: 'astrale-service' }))
|
|
60
|
+
app.post(SERVICE_FUNCTIONS_PATH, async (c) => {
|
|
61
|
+
const parsed = ServiceFunctionDiscoveryRequestSchema.safeParse(
|
|
62
|
+
await c.req.json().catch(() => null),
|
|
63
|
+
)
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
return c.json({ error: 'Invalid service function discovery request' }, 400)
|
|
66
|
+
}
|
|
67
|
+
const response = await signServiceFunctionManifest(
|
|
68
|
+
{
|
|
69
|
+
version: 1,
|
|
70
|
+
service: {
|
|
71
|
+
issuer: serviceIdentity.issuer,
|
|
72
|
+
subject: serviceIdentity.subject,
|
|
73
|
+
},
|
|
74
|
+
functions: toServiceFunctionManifestEntries(schemas),
|
|
75
|
+
},
|
|
76
|
+
parsed.data,
|
|
77
|
+
serviceIdentity,
|
|
78
|
+
)
|
|
79
|
+
return c.json(response)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
mountAuxiliaryRoutes({
|
|
83
|
+
app,
|
|
84
|
+
url,
|
|
85
|
+
remoteFunctions: functions,
|
|
86
|
+
remoteFunctionBindings: bindings,
|
|
87
|
+
deps: env,
|
|
88
|
+
resolveIdentity: (kind, slug) => {
|
|
89
|
+
if (kind !== 'remoteFunction') {
|
|
90
|
+
throw new Error(`serviceWorkerEntry cannot resolve ${kind} identity ${slug}`)
|
|
91
|
+
}
|
|
92
|
+
return resolveFunctionIdentity(slug)
|
|
93
|
+
},
|
|
94
|
+
cors: config.cors ?? { origin: '*' },
|
|
95
|
+
})
|
|
96
|
+
return app
|
|
97
|
+
},
|
|
98
|
+
resolveUrl: config.resolveUrl ?? ((_env, requestOrigin) => requestOrigin),
|
|
99
|
+
selfBinding: config.selfBinding,
|
|
100
|
+
routeSubrequest: config.routeSubrequest,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function identityFromEnv<TEnv extends ServiceWorkerIdentityEnv>(env: TEnv): RemoteIdentityConfig {
|
|
105
|
+
return {
|
|
106
|
+
issuer: required(env, 'IDENTITY_ISS'),
|
|
107
|
+
subject: required(env, 'IDENTITY_SUB'),
|
|
108
|
+
privateKey: parsePrivateKey(required(env, 'IDENTITY_PRIVATE_KEY')),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createFunctionIdentityResolver<TEnv extends ServiceWorkerIdentityEnv>(
|
|
113
|
+
env: TEnv,
|
|
114
|
+
serviceIdentity: RemoteIdentityConfig,
|
|
115
|
+
): (slug: string) => Promise<RemoteIdentityConfig> {
|
|
116
|
+
const kernelUrl = required(env, 'ASTRALE_KERNEL_AUDIENCE')
|
|
117
|
+
let cachedAt = 0
|
|
118
|
+
let identities = new Map<string, { issuer: string; subject: string }>()
|
|
119
|
+
const ttlMs = 60_000
|
|
120
|
+
|
|
121
|
+
return async (slug) => {
|
|
122
|
+
if (Date.now() - cachedAt >= ttlMs || !identities.has(slug)) {
|
|
123
|
+
const kernel = await makeFunctionContext(serviceIdentity, env, {
|
|
124
|
+
ref: 'service',
|
|
125
|
+
defaultKernelUrl: kernelUrl,
|
|
126
|
+
}).kernel()
|
|
127
|
+
const service = await kernel.get(`@${serviceIdentity.subject}`)
|
|
128
|
+
if (!service) throw new Error(`Service @${serviceIdentity.subject} is not readable`)
|
|
129
|
+
const folder = await kernel.get(`${service.path.raw}/functions`)
|
|
130
|
+
const nodes = folder
|
|
131
|
+
? await (
|
|
132
|
+
await kernel.children(folder.path, {
|
|
133
|
+
classes: [K.$.c('Function').path.class],
|
|
134
|
+
limit: 500,
|
|
135
|
+
})
|
|
136
|
+
).all()
|
|
137
|
+
: []
|
|
138
|
+
identities = readFunctionIdentities(nodes, serviceIdentity.issuer)
|
|
139
|
+
cachedAt = Date.now()
|
|
140
|
+
}
|
|
141
|
+
const identity = identities.get(slug)
|
|
142
|
+
if (!identity) throw new Error(`Function identity function.${slug} is not registered`)
|
|
143
|
+
return hostedFunctionIdentity(identity, serviceIdentity)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reuse only the Service's signing key. A hosted Function's inbound audience
|
|
148
|
+
* defaults to its own issuer; the Service HTTP origin is merely transport. */
|
|
149
|
+
export function hostedFunctionIdentity(
|
|
150
|
+
identity: { issuer: string; subject: string },
|
|
151
|
+
serviceIdentity: RemoteIdentityConfig,
|
|
152
|
+
): RemoteIdentityConfig {
|
|
153
|
+
return { ...identity, privateKey: serviceIdentity.privateKey }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readFunctionIdentities(
|
|
157
|
+
nodes: readonly Node[],
|
|
158
|
+
serviceIssuer: string,
|
|
159
|
+
): Map<string, { issuer: string; subject: string }> {
|
|
160
|
+
const result = new Map<string, { issuer: string; subject: string }>()
|
|
161
|
+
for (const node of nodes) {
|
|
162
|
+
const ref = node.props[K.$.i('Function').ref.key]
|
|
163
|
+
const issuer = node.props[K.Identity.iss.key]
|
|
164
|
+
const subject = node.props[K.Identity.sub.key]
|
|
165
|
+
if (
|
|
166
|
+
typeof ref !== 'string' ||
|
|
167
|
+
!ref.startsWith('function.') ||
|
|
168
|
+
typeof issuer !== 'string' ||
|
|
169
|
+
typeof subject !== 'string'
|
|
170
|
+
) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (issuer !== serviceIssuer) {
|
|
174
|
+
throw new Error(`Function ${ref} does not share its hosting Service issuer`)
|
|
175
|
+
}
|
|
176
|
+
result.set(ref.slice('function.'.length), { issuer, subject })
|
|
177
|
+
}
|
|
178
|
+
return result
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function assertLocalBindings(url: string, bindings: Record<string, FunctionBinding>): void {
|
|
182
|
+
const origin = new URL(url).origin
|
|
183
|
+
for (const [slug, binding] of Object.entries(bindings)) {
|
|
184
|
+
if (!binding.remoteUrl || new URL(binding.remoteUrl).origin !== origin) {
|
|
185
|
+
throw new Error(`serviceWorkerEntry function.${slug} must be hosted by this Service origin`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function required<TEnv extends ServiceWorkerIdentityEnv>(env: TEnv, key: keyof TEnv): string {
|
|
191
|
+
const value = env[key]
|
|
192
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
193
|
+
throw new Error(`Missing service variable ${String(key)}`)
|
|
194
|
+
}
|
|
195
|
+
return value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parsePrivateKey(value: string): JsonWebKey {
|
|
199
|
+
let parsed: unknown
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(value)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error('IDENTITY_PRIVATE_KEY is not valid JSON', { cause: error })
|
|
204
|
+
}
|
|
205
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
206
|
+
throw new Error('IDENTITY_PRIVATE_KEY is not a JWK object')
|
|
207
|
+
}
|
|
208
|
+
return parsed as JsonWebKey
|
|
209
|
+
}
|
|
@@ -30,8 +30,8 @@ import { createRemoteServer } from './create.js'
|
|
|
30
30
|
import { requireEnv } from './require-env.js'
|
|
31
31
|
import { canonicalizeServingUrl } from './serving-url.js'
|
|
32
32
|
|
|
33
|
-
type Fetcher = { fetch(request: Request): Response | Promise<Response> }
|
|
34
|
-
type
|
|
33
|
+
export type Fetcher = { fetch(request: Request): Response | Promise<Response> }
|
|
34
|
+
export type WorkerApp = {
|
|
35
35
|
fetch(
|
|
36
36
|
request: Request,
|
|
37
37
|
env?: unknown,
|
|
@@ -41,7 +41,7 @@ type App = {
|
|
|
41
41
|
|
|
42
42
|
/** A built app cached by its serving URL: `origin` for same-origin matching,
|
|
43
43
|
* `app` for in-process self-dispatch. */
|
|
44
|
-
type CachedApp = { origin: string; app:
|
|
44
|
+
type CachedApp = { origin: string; app: WorkerApp }
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Choose where an OUTBOUND subrequest to `u` is routed — or `null` to let the
|
|
@@ -137,6 +137,10 @@ export interface WorkerEntryConfig<TDeps> {
|
|
|
137
137
|
rewriteRequest?: (env: TDeps, request: Request) => Request
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
export interface AppWorkerEntryConfig<TDeps> extends Omit<WorkerEntryConfig<TDeps>, 'build'> {
|
|
141
|
+
buildApp: (url: string, env: TDeps) => WorkerApp
|
|
142
|
+
}
|
|
143
|
+
|
|
140
144
|
export interface WorkerEntry<TDeps> {
|
|
141
145
|
fetch(request: Request, env: TDeps, executionCtx?: ExecutionContext): Response | Promise<Response>
|
|
142
146
|
}
|
|
@@ -201,6 +205,15 @@ export function assets<TDeps>(opts: {
|
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
export function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): WorkerEntry<TDeps> {
|
|
208
|
+
return createAppWorkerEntry({
|
|
209
|
+
...config,
|
|
210
|
+
buildApp: (url, env) => createRemoteServer<TDeps>(config.build(url, env)).app,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createAppWorkerEntry<TDeps>(
|
|
215
|
+
config: AppWorkerEntryConfig<TDeps>,
|
|
216
|
+
): WorkerEntry<TDeps> {
|
|
204
217
|
// Cache the built app per distinct resolved URL — plural and bounded. On the
|
|
205
218
|
// request-origin fallback the URL legitimately alternates for one worker
|
|
206
219
|
// (direct http hits vs https-upgraded tunnel hits, workers.dev + custom
|
|
@@ -212,10 +225,10 @@ export function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): Work
|
|
|
212
225
|
let self: Fetcher | null = null
|
|
213
226
|
let routeEnv: TDeps | null = null
|
|
214
227
|
|
|
215
|
-
function getApp(url: string, env: TDeps):
|
|
228
|
+
function getApp(url: string, env: TDeps): WorkerApp {
|
|
216
229
|
const cached = apps.get(url)
|
|
217
230
|
if (cached) return cached.app
|
|
218
|
-
const
|
|
231
|
+
const app = config.buildApp(url, env)
|
|
219
232
|
if (apps.size >= MAX_CACHED_APPS) {
|
|
220
233
|
const oldest = apps.keys().next().value
|
|
221
234
|
if (oldest !== undefined) apps.delete(oldest)
|