@astrale-os/sdk 0.4.13 → 0.4.14

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.
Files changed (46) hide show
  1. package/dist/domain/extend-functions.d.ts.map +1 -1
  2. package/dist/domain/extend-functions.js +2 -1
  3. package/dist/domain/extend-functions.js.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/linter/project.d.ts +6 -0
  9. package/dist/linter/project.d.ts.map +1 -1
  10. package/dist/linter/project.js +69 -7
  11. package/dist/linter/project.js.map +1 -1
  12. package/dist/server/auxiliary-routes.d.ts +5 -1
  13. package/dist/server/auxiliary-routes.d.ts.map +1 -1
  14. package/dist/server/auxiliary-routes.js +15 -6
  15. package/dist/server/auxiliary-routes.js.map +1 -1
  16. package/dist/server/index.d.ts +4 -2
  17. package/dist/server/index.d.ts.map +1 -1
  18. package/dist/server/index.js +2 -1
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/service-entry.d.ts +32 -0
  21. package/dist/server/service-entry.d.ts.map +1 -0
  22. package/dist/server/service-entry.js +150 -0
  23. package/dist/server/service-entry.js.map +1 -0
  24. package/dist/server/worker-entry.d.ts +7 -3
  25. package/dist/server/worker-entry.d.ts.map +1 -1
  26. package/dist/server/worker-entry.js +7 -1
  27. package/dist/server/worker-entry.js.map +1 -1
  28. package/dist/service/functions.d.ts +148 -0
  29. package/dist/service/functions.d.ts.map +1 -0
  30. package/dist/service/functions.js +115 -0
  31. package/dist/service/functions.js.map +1 -0
  32. package/dist/service/index.d.ts +3 -0
  33. package/dist/service/index.d.ts.map +1 -0
  34. package/dist/service/index.js +2 -0
  35. package/dist/service/index.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/domain/extend-functions.ts +5 -1
  38. package/src/index.ts +18 -0
  39. package/src/linter/docs/RULES.md +1 -1
  40. package/src/linter/project.ts +88 -6
  41. package/src/server/auxiliary-routes.ts +34 -7
  42. package/src/server/index.ts +10 -2
  43. package/src/server/service-entry.ts +209 -0
  44. package/src/server/worker-entry.ts +18 -5
  45. package/src/service/functions.ts +164 -0
  46. package/src/service/index.ts +18 -0
@@ -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
- await collectSourcePaths(projectRoot, paths)
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 collectSourcePaths(directory: string, result: string[]): Promise<void> {
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)) await collectSourcePaths(path, result)
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
- result.push(path)
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
- if (!specifier.startsWith('.')) return undefined
88
- const unresolved = resolve(dirname(importer.path), specifier)
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: AuxIdentityMap
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 = requireAuxIdentity('view', slug, identities.views[slug])
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 = requireAuxIdentity('remote function', slug, identities.remoteFunctions[slug])
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
- identity,
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) {
@@ -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 { WorkerEntry, WorkerEntryConfig } from './worker-entry.js'
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 App = {
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: 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): App {
228
+ function getApp(url: string, env: TDeps): WorkerApp {
216
229
  const cached = apps.get(url)
217
230
  if (cached) return cached.app
218
- const { app } = createRemoteServer<TDeps>(config.build(url, env))
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)