@jigs.design/server 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,20 @@
1
- import { INTERNAL_MESSAGE, JigsCompileError, JigsError, errorData, fatalDiagnostics, isJigsError, statusOf } from "./errors.js";
2
- import { checkMarkdownElements, createChecker } from "./compile.js";
1
+ import { INTERNAL_MESSAGE, JigsCompileError, JigsError, errorData, fatalDiagnostics, isJigsError } from "./errors.js";
3
2
  import { belowPrefix, splitPragma, versionDiagnostics } from "./pragma.js";
4
- import { MAX_CONTENT, messagesSchema, runSession } from "./session.js";
5
- import { createReplyParser, repairBlock, streamChat } from "./chat.js";
3
+ import { checkMarkdownElements, createChecker } from "./compile.js";
4
+ import { VERSION_HEADER, createHandler, createHttp, issuesText, originOf } from "./handler.js";
5
+ import { MAX_CONTENT, asksModel, parseSessionInput, restoreTurn } from "./session.js";
6
6
  import { confirmMessage, createConfirmer, memoryConfirmations } from "./confirm.js";
7
+ import { processCompileCache, sha256 } from "./compile-cache.js";
8
+ import { eventStream } from "./events.js";
7
9
  import { invalidationsOf } from "./invalidation.js";
10
+ import { createModelServer } from "./model-server.js";
8
11
  import { systemPromptParts } from "@jigs.design/prompt";
9
12
  import { isCallPolicy } from "@jigs.design/host";
10
13
  import { PROGRAM_FORMAT } from "@jigs.design/protocol";
11
14
  import { z } from "zod";
12
15
  import { frameDocument, frameSecurityPolicy } from "@jigs.design/runtime/frame";
13
- //#region src/server.ts
14
- const VERSION_HEADER = "jigs-version";
16
+ //#region src/core.ts
15
17
  const DEFAULT_MAX_BODY_BYTES = 1e6;
16
- const HEARTBEAT_MS = 15e3;
17
18
  /** A small document whose compiled code changes when the compiler's output does. */
18
19
  const COMPILER_PROBE = `{@body const [count, setCount] = useState(0)}
19
20
  # Probe
@@ -23,62 +24,18 @@ const COMPILER_PROBE = `{@body const [count, setCount] = useState(0)}
23
24
  <if test={count > 0}>
24
25
  **done**
25
26
  </if>`;
26
- /**
27
- * The body as text, or null once it passes `max` bytes. Reading stops there,
28
- * so an oversized body is never held in memory.
29
- */
30
- async function readLimited(request, max) {
31
- if (request.body === null) return "";
32
- const reader = request.body.getReader();
33
- const chunks = [];
34
- let size = 0;
35
- for (;;) {
36
- const chunk = await reader.read();
37
- if (chunk.done) break;
38
- size += chunk.value.byteLength;
39
- if (size > max) {
40
- await reader.cancel();
41
- return null;
42
- }
43
- chunks.push(chunk.value);
44
- }
45
- const bytes = new Uint8Array(size);
46
- let offset = 0;
47
- for (const chunk of chunks) {
48
- bytes.set(chunk, offset);
49
- offset += chunk.byteLength;
50
- }
51
- return new TextDecoder().decode(bytes);
27
+ /** What `resolvePage` must return, checked, since a plain-JavaScript one may not. */
28
+ const savedPageSchema = z.object({ source: z.string() });
29
+ /** The strict rule: an error or warning diagnostic throws. */
30
+ function strictly(subject, compiled) {
31
+ const fatal = fatalDiagnostics(compiled.diagnostics);
32
+ if (fatal.length > 0) throw new JigsCompileError(subject, fatal);
33
+ return compiled;
52
34
  }
53
- /**
54
- * An origin such as `https://app.example.com`. Throws on anything else,
55
- * including an entry with no `http:` or `https:` scheme: `localhost:3000`
56
- * parses as a URL whose origin is `"null"`, the `Origin` that sandboxed
57
- * frames and `file:` pages send.
58
- */
59
- function originOf(entry) {
60
- let url = null;
61
- try {
62
- url = new URL(entry);
63
- } catch {}
64
- if (url === null || url.protocol !== "http:" && url.protocol !== "https:" || url.origin === "null") throw new Error(`"${entry}" is not an origin: write its scheme and host, such as https://app.example.com`);
65
- return url.origin;
35
+ /** A document page, shipped or saved, takes no props. */
36
+ function noProps(name, props) {
37
+ if (Object.keys(props).length > 0) throw new JigsError("BAD_INPUT", `"${name}" is a document page, which takes no props`);
66
38
  }
67
- const bodySchema = z.discriminatedUnion("type", [z.object({
68
- confirmation: z.string().max(1e3).optional(),
69
- input: z.json().optional(),
70
- kind: z.enum([
71
- "action",
72
- "mutation",
73
- "query"
74
- ], { error: "Send the procedure's kind as \"kind\": \"query\", \"mutation\" or \"action\"" }),
75
- name: z.string().max(200),
76
- type: z.literal("call")
77
- }), z.object({
78
- messages: z.json(),
79
- options: z.json().optional(),
80
- type: z.literal("session")
81
- })]);
82
39
  /** A call a policy refused. The procedure stays in the prompt. */
83
40
  const denial = (name) => ({ error: {
84
41
  code: "FORBIDDEN",
@@ -136,47 +93,16 @@ function hostFingerprint(host) {
136
93
  output: procedure.output === void 0 ? null : z.toJSONSchema(procedure.output),
137
94
  projection: procedure.kind === "action" || procedure.projection === void 0 ? null : z.toJSONSchema(procedure.projection.schema)
138
95
  })),
139
- recipes: host.recipes.map((entry) => [entry.name, entry.source])
96
+ jigs: host.jigs.map((entry) => [
97
+ entry.name,
98
+ splitPragma(entry.source).body,
99
+ entry.kind,
100
+ entry.page,
101
+ entry.tag
102
+ ])
140
103
  });
141
104
  }
142
105
  /**
143
- * What `jigs.chat` reads from the route besides the request. `blocks` and
144
- * `prompt` come from the browser, so they are checked like a session's
145
- * messages.
146
- */
147
- const chatInputSchema = z.object({
148
- blocks: z.array(z.object({
149
- id: z.string().max(200),
150
- source: z.string().max(MAX_CONTENT)
151
- })).max(200).optional(),
152
- prompt: z.string().max(MAX_CONTENT).optional()
153
- });
154
- /**
155
- * Runs a `safeParse`, with a value nested too deeply to walk reported as a
156
- * failure instead of a thrown `RangeError`.
157
- */
158
- function parseBounded(parse) {
159
- let parsed;
160
- try {
161
- parsed = parse();
162
- } catch {
163
- return {
164
- message: "The request body is nested too deeply.",
165
- success: false
166
- };
167
- }
168
- return parsed.success ? {
169
- data: parsed.data,
170
- success: true
171
- } : {
172
- message: issuesText(parsed.error),
173
- success: false
174
- };
175
- }
176
- function issuesText(error) {
177
- return error.issues.slice(0, 3).map((issue) => issue.path.length === 0 ? issue.message : `${issue.path.join(".")}: ${issue.message}`).join("; ");
178
- }
179
- /**
180
106
  * Types a `context` and `run` pair outside `createJigsServer`, so two
181
107
  * servers (one per platform) can share them. `TContext` comes from `context`
182
108
  * alone, as in `createJigsServer`.
@@ -198,23 +124,20 @@ function createJigsServer(options) {
198
124
  const chatParts = systemPromptParts(host, promptOptions("chat"));
199
125
  const system = `${documentParts.stable}\n\n${documentParts.variable}`;
200
126
  const chatSystem = `${chatParts.stable}\n\n${chatParts.variable}`;
201
- /** Tags that mark unfenced Jigs in a chat reply. */
202
- const chatTags = /* @__PURE__ */ new Set([...host.components.components.map((component) => component.name), ...host.recipes.map((entry) => entry.name)]);
203
- const maxRepairs = options.maxRepairs ?? 1;
204
- const programIntervalMs = options.programIntervalMs ?? 250;
205
- const designRepair = new Set(options.designRepair ?? []);
206
127
  const allowedOrigins = new Set((options.allowedOrigins ?? []).map((entry) => originOf(entry)));
207
128
  const ownOrigin = options.origin === void 0 ? null : originOf(options.origin);
208
129
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
209
130
  const frameHtml = frameDocument();
210
131
  const compilerProbe = checker.compile(COMPILER_PROBE).program.code;
211
- const version = fingerprint(JSON.stringify([
132
+ const fingerprintInput = JSON.stringify([
212
133
  PROGRAM_FORMAT,
213
134
  0,
214
135
  frameHtml,
215
136
  compilerProbe,
216
137
  hostFingerprint(host)
217
- ]));
138
+ ]);
139
+ const version = fingerprint(fingerprintInput);
140
+ const namespace = sha256(fingerprintInput);
218
141
  const onError = (cause, info) => {
219
142
  try {
220
143
  options.onError?.(cause, info);
@@ -392,7 +315,7 @@ function createJigsServer(options) {
392
315
  const failed = (cause) => {
393
316
  onError(cause, {
394
317
  name: report.name,
395
- type: "call"
318
+ type: report.kind === "page" ? "page" : "call"
396
319
  });
397
320
  };
398
321
  try {
@@ -423,26 +346,6 @@ function createJigsServer(options) {
423
346
  reportCall(report);
424
347
  return outcome;
425
348
  };
426
- const parseSession = (body) => {
427
- const messages = messagesSchema.safeParse(body.messages);
428
- if (!messages.success) return { error: {
429
- code: "BAD_INPUT",
430
- message: `messages: ${issuesText(messages.error)}`
431
- } };
432
- if (options.options === void 0) return {
433
- messages: messages.data,
434
- options: void 0
435
- };
436
- const parsed = options.options.safeParse(body.options ?? {});
437
- if (!parsed.success) return { error: {
438
- code: "BAD_INPUT",
439
- message: `options: ${issuesText(parsed.error)}`
440
- } };
441
- return {
442
- messages: messages.data,
443
- options: parsed.data
444
- };
445
- };
446
349
  /** A stored source: its pragma's versions are checked, then its body compiled. */
447
350
  const compileStored = (source, placement = "document") => {
448
351
  const stored = splitPragma(source);
@@ -454,157 +357,230 @@ function createJigsServer(options) {
454
357
  program: compiled.program
455
358
  };
456
359
  };
457
- /** The strict rule: an error or warning diagnostic throws. */
458
- const compileStrict = (subject, source, placement) => {
459
- const compiled = compileStored(source, placement);
460
- const fatal = fatalDiagnostics(compiled.diagnostics);
461
- if (fatal.length > 0) throw new JigsCompileError(subject, fatal);
462
- return compiled;
463
- };
464
- const specs = /* @__PURE__ */ new Map();
465
- for (const [name, source] of Object.entries(options.specs ?? {})) specs.set(name, compileStrict(`Spec "${name}"`, source).program);
466
- const spec = (name) => {
467
- const program = specs.get(name);
468
- if (program === void 0) throw new Error(`createJigsServer has no spec named "${name}"`);
469
- return program;
470
- };
471
- const compile = async (source, compileOptions) => compileOptions?.strict === false ? compileStored(source, compileOptions.placement) : compileStrict("The source", source, compileOptions?.placement);
472
- const sessionDeps = {
473
- checker,
474
- designRepair,
475
- host,
476
- kind: "session",
477
- maxRepairs,
478
- model: options.model,
479
- onError,
480
- onTurn: options.onTurn,
481
- programIntervalMs,
482
- system,
483
- systemPrefix: documentParts.stable
484
- };
485
- const serverOrigin = (request) => ownOrigin ?? new URL(request.url).origin;
486
360
  /**
487
- * Why a POST is refused before anything runs: an `Origin` that may not
488
- * call this server, or a body that is not JSON, which a cross-site form
489
- * can send without a preflight. Null when it may go on.
361
+ * `compile` through the process's cache, keyed by what this server
362
+ * compiles against, the placement (or "page", for a define page's body,
363
+ * which compiles against every define jig) and the body's hash. Only
364
+ * final artifacts come here: a reply's partial sources stay on `checker`.
490
365
  */
491
- const refusal = (request) => {
492
- const origin = request.headers.get("origin");
493
- if (origin !== null && origin !== serverOrigin(request) && !allowedOrigins.has(origin)) return {
494
- code: "FORBIDDEN",
495
- message: "This origin may not call this server."
366
+ const cached = async (placement, body, compile) => {
367
+ const key = `${await namespace}.${placement}.${await sha256(body)}`;
368
+ const hit = processCompileCache.get(key);
369
+ if (hit !== void 0) return hit;
370
+ const compiled = compile();
371
+ processCompileCache.set(key, compiled);
372
+ return compiled;
373
+ };
374
+ const compileCached = (source, placement = "document") => cached(placement, source, () => compileStored(source, placement));
375
+ const jigsByName = new Map(host.jigs.map((entry) => [entry.name, entry]));
376
+ /** A define page's one-tag body compiled; it throws as `pageBody` does. */
377
+ const compileDefine = (name, props) => {
378
+ const compiled = checker.page(name, props);
379
+ return {
380
+ advice: compiled.advise("document"),
381
+ diagnostics: compiled.diagnostics,
382
+ program: compiled.program
496
383
  };
497
- if (!(request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) return {
498
- code: "BAD_INPUT",
499
- message: "Send the request body as application/json."
384
+ };
385
+ const startupPages = /* @__PURE__ */ new Map();
386
+ for (const entry of host.jigs) {
387
+ if (!entry.page) continue;
388
+ const subject = `Jig "${entry.name}"`;
389
+ if (entry.kind === "document") startupPages.set(entry.name, {
390
+ ...strictly(subject, compileStored(entry.source)),
391
+ origin: "shipped",
392
+ source: entry.source
393
+ });
394
+ else if (!checker.needsProps(entry.name)) startupPages.set(entry.name, {
395
+ ...strictly(subject, compileDefine(entry.name, {})),
396
+ origin: "shipped",
397
+ source: checker.pageBody(entry.name, {})
398
+ });
399
+ }
400
+ /** A page the host lists, compiled strictly. */
401
+ const shippedPage = async (entry, props) => {
402
+ const ready = startupPages.get(entry.name);
403
+ if (entry.kind === "document") noProps(entry.name, props);
404
+ if (ready !== void 0 && Object.keys(props).length === 0) return ready;
405
+ const body = checker.pageBody(entry.name, props);
406
+ const compiled = await cached("page", body, () => compileDefine(entry.name, props));
407
+ return {
408
+ ...strictly(`Page "${entry.name}"`, compiled),
409
+ origin: "shipped",
410
+ source: body
500
411
  };
501
- return null;
502
412
  };
503
- const chat = async (request, chatOptions) => {
504
- const refused = refusal(request);
505
- if (refused !== null) throw new JigsError(refused.code, refused.message);
506
- let ctx;
413
+ const internal = (cause, name) => {
414
+ onError(cause, {
415
+ name,
416
+ type: "page"
417
+ });
418
+ return new JigsError("INTERNAL", INTERNAL_MESSAGE);
419
+ };
420
+ /** The source `resolvePage` finds for `call.ctx`, checked; null when it finds none. */
421
+ const savedSource = async (name, props, lookup) => {
422
+ if (options.resolvePage === void 0) return null;
423
+ let found;
507
424
  try {
508
- ctx = await options.context(request);
425
+ found = await options.resolvePage(name, lookup.signal === void 0 ? { ctx: lookup.ctx } : {
426
+ ctx: lookup.ctx,
427
+ signal: lookup.signal
428
+ });
509
429
  } catch (error) {
510
430
  if (isJigsError(error)) throw error;
511
- onError(error, { type: "chat" });
512
- throw new JigsError("INTERNAL", INTERNAL_MESSAGE);
513
- }
514
- const input = parseBounded(() => chatInputSchema.safeParse({
515
- blocks: chatOptions?.blocks,
516
- prompt: chatOptions?.prompt
517
- }));
518
- if (!input.success) throw new JigsError("BAD_INPUT", input.message);
519
- let turnOptions;
520
- if (options.options === void 0) turnOptions = void 0;
521
- else {
522
- const schema = options.options;
523
- const parsed = parseBounded(() => schema.safeParse(chatOptions?.options ?? {}));
524
- if (!parsed.success) throw new JigsError("BAD_INPUT", `options: ${parsed.message}`);
525
- turnOptions = parsed.data;
431
+ throw internal(error, name);
526
432
  }
527
- const turnCall = {
528
- ctx,
529
- signal: chatOptions?.signal ?? new AbortController().signal
530
- };
531
- const repairRounds = Math.max(0, maxRepairs - 1);
532
- const parser = createReplyParser({
533
- catalogVersion: host.components.version,
534
- compile: (body) => checker.compile(body),
535
- designRepair,
536
- now: () => performance.now(),
537
- programIntervalMs,
538
- repairs: options.model !== void 0 && maxRepairs > 0,
539
- tags: chatTags
540
- }, input.data.blocks ?? []);
541
- const turnDeps = {
542
- call: turnCall,
543
- options: turnOptions,
544
- parser,
545
- prompt: input.data.prompt,
546
- session: {
547
- ...sessionDeps,
548
- kind: "chat-repair",
549
- maxRepairs: repairRounds
550
- }
551
- };
552
- const repair = (block) => repairBlock(turnDeps, block);
433
+ if (found === null) return null;
434
+ const checked = savedPageSchema.safeParse(found);
435
+ if (!checked.success) throw internal(/* @__PURE__ */ new Error(`resolvePage returned ${String(found)} for "${name}"; return { source } or null`), name);
436
+ const source = checked.data.source;
437
+ if (source.length > 2e5) throw internal(/* @__PURE__ */ new Error(`The source resolvePage returned for "${name}" is longer than ${MAX_CONTENT} characters`), name);
438
+ noProps(name, props);
439
+ return source;
440
+ };
441
+ /** A page `resolvePage` finds for `call.ctx`, compiled leniently unless `strict`. */
442
+ const savedPage = async (name, props, lookup) => {
443
+ const source = await savedSource(name, props, lookup);
444
+ if (source === null) return null;
445
+ const compiled = await compileCached(source);
553
446
  return {
554
- end: parser.end,
555
- push: parser.push,
556
- repair,
557
- results: parser.results,
558
- stream: (reply) => streamChat({
559
- end: parser.end,
560
- push: parser.push,
561
- repair
562
- }, reply, chatOptions?.signal),
563
- system: chatSystem,
564
- systemPrefix: chatParts.stable
447
+ ...lookup.strict === true ? strictly(`Page "${name}"`, compiled) : compiled,
448
+ origin: "saved",
449
+ source
565
450
  };
566
451
  };
567
- async function* session(body, context) {
568
- const parsed = parseSession(body);
569
- if ("error" in parsed) {
570
- yield {
571
- error: parsed.error,
572
- type: "error"
452
+ const resolve = async (name, props = {}, lookup) => {
453
+ const entry = jigsByName.get(name);
454
+ if (entry?.page === true) return shippedPage(entry, props);
455
+ if (lookup === void 0 || !("ctx" in lookup)) throw new Error(`createJigsServer has no page named "${name}"`);
456
+ return entry === void 0 ? savedPage(name, props, lookup) : null;
457
+ };
458
+ const page = resolve;
459
+ const compile = async (source, compileOptions) => {
460
+ const compiled = await compileCached(source, compileOptions?.placement);
461
+ return compileOptions?.strict === false ? compiled : strictly("The source", compiled);
462
+ };
463
+ /** Whether `pagePolicy` lets this `ctx` read a shipped page; it fails closed. */
464
+ const pageAllowed = (ctx, name, props) => {
465
+ if (options.pagePolicy === void 0) return true;
466
+ try {
467
+ const decided = options.pagePolicy({
468
+ ctx,
469
+ name,
470
+ props
471
+ });
472
+ if (decided !== "allow" && decided !== "deny") throw new Error(`pagePolicy returned ${String(decided)} for "${name}"; return "allow" or "deny", synchronously`);
473
+ return decided === "allow";
474
+ } catch (error) {
475
+ onError(error, {
476
+ name,
477
+ type: "page"
478
+ });
479
+ return false;
480
+ }
481
+ };
482
+ /**
483
+ * A page request from the wire, after `context`: `pagePolicy` for a
484
+ * shipped page, then `page` with `ctx`. A denied page and one no list
485
+ * has answer alike, so a denial does not say the page exists.
486
+ */
487
+ const pageRequest = async (request, context) => {
488
+ const started = performance.now();
489
+ const { name, props } = request;
490
+ const notFound = {
491
+ code: "NOT_FOUND",
492
+ message: `There is no page named "${name}".`
493
+ };
494
+ let outcome;
495
+ let denied = false;
496
+ const shippedName = name;
497
+ if (jigsByName.get(name)?.page === true && !pageAllowed(context.ctx, shippedName, props)) {
498
+ denied = true;
499
+ outcome = { error: notFound };
500
+ } else try {
501
+ const resolved = await page(name, props, context);
502
+ outcome = resolved === null ? { error: notFound } : {
503
+ diagnostics: resolved.diagnostics,
504
+ program: resolved.program,
505
+ version
573
506
  };
574
- return;
507
+ } catch (error) {
508
+ if (!isJigsError(error)) onError(error, {
509
+ name,
510
+ type: "page"
511
+ });
512
+ outcome = { error: errorData(isJigsError(error) ? error : null) };
575
513
  }
576
- yield* runSession(sessionDeps, parsed.messages, parsed.options, context);
577
- }
578
- const tooLarge = (request) => failure(request, {
579
- code: "BAD_INPUT",
580
- message: `The request body is larger than ${maxBodyBytes} bytes.`
581
- });
582
- const corsHeaders = (request) => {
583
- const headers = new Headers({ [VERSION_HEADER]: version });
584
- const origin = request.headers.get("origin");
585
- if (origin !== null && allowedOrigins.has(origin)) {
586
- headers.set("access-control-allow-origin", origin);
587
- headers.set("access-control-allow-credentials", "true");
588
- headers.set("access-control-expose-headers", VERSION_HEADER);
589
- headers.append("vary", "Origin");
514
+ const report = {
515
+ ctx: context.ctx,
516
+ durationMs: performance.now() - started,
517
+ input: props,
518
+ kind: "page",
519
+ name,
520
+ outcome: "success"
521
+ };
522
+ if ("error" in outcome) {
523
+ report.error = outcome.error;
524
+ report.outcome = denied ? "denied" : "error";
590
525
  }
591
- return headers;
526
+ reportCall(report);
527
+ return outcome;
592
528
  };
593
- const json = (request, status, value) => {
594
- const headers = corsHeaders(request);
595
- headers.set("content-type", "application/json");
596
- headers.set("cache-control", "no-store");
597
- return new Response(JSON.stringify(value), {
598
- headers,
599
- status
600
- });
529
+ /**
530
+ * The source a session opens for a page by name, or null when this
531
+ * `ctx` has no such page: a shipped page `pagePolicy` allows, as a
532
+ * self-contained document for a define page, or a saved one. A tag that
533
+ * is not a page is not one a session may open.
534
+ */
535
+ const pageSource = async (name, props, context) => {
536
+ const entry = jigsByName.get(name);
537
+ if (entry === void 0) return savedSource(name, props, context);
538
+ const shippedName = name;
539
+ if (!entry.page || !pageAllowed(context.ctx, shippedName, props)) return null;
540
+ if (entry.kind === "document") {
541
+ noProps(name, props);
542
+ return entry.source;
543
+ }
544
+ return checker.pageDocument(name, props);
601
545
  };
602
- const failure = (request, error) => json(request, statusOf(error.code), { error });
546
+ const prepareSession = async (body, context) => {
547
+ const input = parseSessionInput(body);
548
+ if ("error" in input) return input;
549
+ const document = input.document;
550
+ if (document === null || "source" in document) return {
551
+ document: document?.source ?? null,
552
+ messages: input.messages
553
+ };
554
+ let source;
555
+ try {
556
+ source = await pageSource(document.name, document.props ?? {}, context);
557
+ } catch (error) {
558
+ if (!isJigsError(error)) onError(error, {
559
+ name: document.name,
560
+ type: "page"
561
+ });
562
+ return { error: errorData(isJigsError(error) ? error : null) };
563
+ }
564
+ if (source === null) return { error: {
565
+ code: "NOT_FOUND",
566
+ message: `There is no page named "${document.name}".`
567
+ } };
568
+ return {
569
+ document: source,
570
+ messages: input.messages
571
+ };
572
+ };
573
+ const http = createHttp({
574
+ allowedOrigins,
575
+ ownOrigin,
576
+ version
577
+ });
603
578
  const frame = (request) => {
604
579
  const ancestors = ["'self'", ...allowedOrigins].join(" ");
605
- const headers = request === void 0 ? new Headers() : corsHeaders(request);
580
+ const headers = request === void 0 ? new Headers() : http.corsHeaders(request);
606
581
  if (headers.has("access-control-allow-origin")) headers.set("access-control-expose-headers", `${VERSION_HEADER}, content-security-policy`);
607
582
  headers.set("cache-control", "no-cache");
583
+ headers.append("vary", "Accept");
608
584
  headers.set("content-security-policy", `${frameSecurityPolicy}; frame-ancestors ${ancestors}`);
609
585
  headers.set("content-type", "text/html; charset=utf-8");
610
586
  headers.set("etag", `"${version}"`);
@@ -631,156 +607,94 @@ function createJigsServer(options) {
631
607
  } };
632
608
  }
633
609
  };
634
- const stream = (request, events, abort, cleanup) => {
635
- const encoder = new TextEncoder();
636
- const iterator = events[Symbol.asyncIterator]();
637
- let heartbeat;
638
- let open = true;
639
- const finish = () => {
640
- open = false;
641
- clearInterval(heartbeat);
642
- cleanup();
643
- };
644
- const body = new ReadableStream({
645
- async cancel() {
646
- finish();
647
- abort.abort();
648
- await iterator.return?.();
649
- },
650
- async pull(controller) {
651
- let next;
652
- try {
653
- next = await iterator.next();
654
- } catch (error) {
655
- onError(error, { type: "session" });
656
- finish();
657
- const event = {
658
- error: {
659
- code: "INTERNAL",
660
- message: INTERNAL_MESSAGE
661
- },
662
- type: "error"
663
- };
664
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
665
- controller.close();
666
- return;
667
- }
668
- if (next.done === true) {
669
- finish();
670
- controller.close();
671
- return;
672
- }
673
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`));
674
- },
675
- start(controller) {
676
- heartbeat = setInterval(() => {
677
- if (!open) return;
678
- try {
679
- controller.enqueue(encoder.encode(": keep-alive\n\n"));
680
- } catch {
681
- finish();
682
- }
683
- }, HEARTBEAT_MS);
684
- }
685
- });
686
- const headers = corsHeaders(request);
687
- headers.set("content-type", "text/event-stream; charset=utf-8");
688
- headers.set("cache-control", "no-store");
689
- headers.set("x-accel-buffering", "no");
690
- return new Response(body, {
610
+ /**
611
+ * A session turn with no model: a document that asks nothing is
612
+ * compiled again; a turn that would ask the model is not found here.
613
+ */
614
+ const restoreOnly = async (body, context) => {
615
+ const prepared = await prepareSession(body, context);
616
+ if ("error" in prepared) return prepared;
617
+ const document = prepared.document;
618
+ if (document === null || asksModel(prepared.messages)) return { error: {
619
+ code: "NOT_FOUND",
620
+ message: "This server has no model, so it cannot answer a turn that asks one."
621
+ } };
622
+ return { async *turn() {
623
+ yield* restoreTurn(checker, host.components.version, document, true);
624
+ } };
625
+ };
626
+ const hostEvents = options.events;
627
+ /**
628
+ * The push stream's answer before `ctx` is needed: a foreign `Origin` is
629
+ * refused, and a server without `events` says so with `204`. Null when
630
+ * the stream may open.
631
+ */
632
+ const eventsRefusal = (request) => {
633
+ const foreign = http.foreignOrigin(request);
634
+ if (foreign !== null) return http.failure(request, foreign);
635
+ if (hostEvents !== void 0) return null;
636
+ const headers = http.corsHeaders(request);
637
+ headers.append("vary", "Accept");
638
+ return new Response(null, {
691
639
  headers,
692
- status: 200
640
+ status: 204
693
641
  });
694
642
  };
695
- const handler = async (request) => {
696
- const origin = request.headers.get("origin");
697
- if (request.method === "OPTIONS") {
698
- const headers = corsHeaders(request);
699
- if (origin !== null && allowedOrigins.has(origin)) {
700
- headers.set("access-control-allow-methods", "GET, POST");
701
- headers.set("access-control-allow-headers", request.headers.get("access-control-request-headers") ?? "content-type");
702
- headers.set("access-control-max-age", "600");
703
- }
704
- return new Response(null, {
705
- headers,
706
- status: 204
707
- });
708
- }
709
- if (request.method === "GET") return frame(request);
710
- if (request.method !== "POST") {
711
- const headers = corsHeaders(request);
712
- headers.set("allow", "GET, POST, OPTIONS");
713
- return new Response(null, {
714
- headers,
715
- status: 405
716
- });
717
- }
718
- const refused = refusal(request);
719
- if (refused !== null) return failure(request, refused);
720
- if (Number(request.headers.get("content-length") ?? "0") > maxBodyBytes) return tooLarge(request);
721
- let text;
722
- try {
723
- text = await readLimited(request, maxBodyBytes);
724
- } catch {
725
- return failure(request, {
726
- code: "BAD_INPUT",
727
- message: "The request body could not be read."
728
- });
729
- }
730
- if (text === null) return tooLarge(request);
731
- let raw;
732
- try {
733
- raw = JSON.parse(text);
734
- } catch {
735
- return failure(request, {
736
- code: "BAD_INPUT",
737
- message: "The request body is not JSON."
738
- });
739
- }
740
- const body = parseBounded(() => bodySchema.safeParse(raw));
741
- if (!body.success) return failure(request, {
742
- code: "BAD_INPUT",
743
- message: body.message
744
- });
745
- const context = await contextFor(request, body.data.type === "call" ? "call" : "session");
746
- if ("error" in context) return failure(request, context.error);
747
- if (body.data.type === "call") {
748
- const callRequest = {
749
- input: body.data.input,
750
- kind: body.data.kind,
751
- name: body.data.name
752
- };
753
- if (body.data.confirmation !== void 0) callRequest.confirmation = body.data.confirmation;
754
- const outcome = await call(callRequest, {
755
- ctx: context.ctx,
756
- signal: request.signal
757
- });
758
- return json(request, "error" in outcome ? statusOf(outcome.error.code) : 200, outcome);
759
- }
760
- const parsed = parseSession(body.data);
761
- if ("error" in parsed) return failure(request, parsed.error);
762
- const abort = new AbortController();
763
- const forward = () => abort.abort();
764
- if (request.signal.aborted) abort.abort();
765
- else request.signal.addEventListener("abort", forward, { once: true });
766
- return stream(request, runSession(sessionDeps, parsed.messages, parsed.options, {
643
+ const events = async (request, context) => {
644
+ const refused = eventsRefusal(request);
645
+ if (refused !== null || hostEvents === void 0) return refused ?? new Response(null, { status: 204 });
646
+ return eventStream({
647
+ events: hostEvents,
648
+ http,
649
+ onError
650
+ }, request, context);
651
+ };
652
+ /** The handler's route for a `GET` that accepts `text/event-stream`. */
653
+ const eventsRoute = async (request) => {
654
+ const refused = eventsRefusal(request);
655
+ if (refused !== null) return refused;
656
+ const context = await contextFor(request, "events");
657
+ if ("error" in context) return http.failure(request, context.error);
658
+ return events(request, {
767
659
  ctx: context.ctx,
768
- signal: abort.signal
769
- }), abort, () => request.signal.removeEventListener("abort", forward));
660
+ signal: request.signal
661
+ });
662
+ };
663
+ const handlerDeps = {
664
+ allowedOrigins,
665
+ call,
666
+ contextFor,
667
+ events: eventsRoute,
668
+ frame,
669
+ http,
670
+ maxBodyBytes,
671
+ onError,
672
+ page: pageRequest
770
673
  };
771
- return {
674
+ const core = {
772
675
  call,
773
- chat,
774
676
  compile,
677
+ events,
775
678
  frame,
776
- handler,
679
+ handler: createHandler({
680
+ ...handlerDeps,
681
+ session: restoreOnly
682
+ }),
777
683
  maxBodyBytes,
684
+ page,
778
685
  prompt: (mode) => mode === "chat" ? chatSystem : system,
779
686
  promptPrefix: (mode) => mode === "chat" ? chatParts.stable : documentParts.stable,
780
- session,
781
- spec,
782
- version
687
+ version,
688
+ withModel: (modelOptions) => createModelServer({
689
+ checker,
690
+ context: options.context,
691
+ handlerDeps,
692
+ host,
693
+ onError,
694
+ prepareSession
695
+ }, core, modelOptions)
783
696
  };
697
+ return core;
784
698
  }
785
699
  //#endregion
786
700
  export { createJigsServer, defineRun };